Initial commit: Handover system with recovered files and train data

This commit is contained in:
9700X_PC 2026-01-18 08:36:51 +09:00
commit 40e2f8ee3e
512 changed files with 573902 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
venv/
build/
__pycache__

516
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,516 @@
# 전동차 업무 인수인계 및 고장관리 프로그램 아키텍처
## 1. 프로젝트 개요
### 1.1 목적
- 전동차 운용실의 24시간 교대 근무 환경에서 업무 인수인계 및 고장관리를 위한 윈도우 데스크톱 애플리케이션
- 기존 엑셀 매크로 기반 시스템을 현대적인 GUI 애플리케이션으로 대체
### 1.2 기술 스택
- **Frontend**: PySide6 (Qt for Python)
- **Database**: SQLite (로컬) → Supabase (원격, 추후 연동)
- **Font**: GmarketSans
- **Platform**: Windows Native
### 1.3 설계 원칙
- 모듈화된 구조로 유지보수성 확보
- 상속 기반 확장성 설계
- 상세한 로깅 및 주석
- 현대적이고 미려한 커스텀 UI
---
## 2. 프로젝트 구조
```
handover/
├── main.py # 진입점
├── requirements.txt # 의존성 패키지
├── ARCHITECTURE.md # 아키텍처 문서
├── config.ini # 설정 파일
├── assets/ # 리소스
│ ├── fonts/ # 폰트 파일
│ │ └── GmarketSans/
│ ├── icons/ # 아이콘
│ ├── images/ # 이미지
│ └── styles/ # QSS 스타일시트
│ ├── dark_theme.qss
│ └── light_theme.qss
├── core/ # 핵심 모듈
│ ├── __init__.py
│ ├── constants.py # 상수 정의
│ ├── config.py # 설정 관리
│ ├── logger.py # 로깅 시스템
│ ├── signals.py # 전역 시그널
│ └── exceptions.py # 커스텀 예외
├── database/ # 데이터베이스 모듈
│ ├── __init__.py
│ ├── db_manager.py # DB 연결 관리
│ ├── models.py # 데이터 모델 정의
│ ├── crud.py # CRUD 연산
│ ├── migrations.py # DB 마이그레이션
│ └── sync_manager.py # 원격 DB 동기화 (추후)
├── services/ # 비즈니스 로직
│ ├── __init__.py
│ ├── auth_service.py # 인증/권한 서비스
│ ├── weather_service.py # 날씨 정보 서비스
│ ├── update_service.py # 업데이트 서비스
│ ├── backup_service.py # 백업 서비스
│ └── notification_service.py # 알림 서비스
├── ui/ # UI 모듈
│ ├── __init__.py
│ ├── main_window.py # 메인 윈도우
│ │
│ ├── base/ # 기본 UI 컴포넌트
│ │ ├── __init__.py
│ │ ├── base_widget.py # 기본 위젯 클래스
│ │ ├── base_dialog.py # 기본 다이얼로그 클래스
│ │ ├── base_section.py # 기본 섹션 클래스
│ │ └── base_table.py # 기본 테이블 클래스
│ │
│ ├── components/ # 재사용 가능 컴포넌트
│ │ ├── __init__.py
│ │ ├── custom_button.py # 커스텀 버튼
│ │ ├── custom_input.py # 커스텀 입력 필드
│ │ ├── custom_table.py # 커스텀 테이블
│ │ ├── custom_calendar.py # 커스텀 캘린더
│ │ ├── toggle_switch.py # 토글 스위치
│ │ ├── dropdown.py # 드롭다운
│ │ ├── splitter.py # 분리바
│ │ ├── popup_widget.py # 팝업 위젯
│ │ └── train_info_popup.py # 편성 정보 팝업
│ │
│ ├── panels/ # 패널 (영역별 UI)
│ │ ├── __init__.py
│ │ ├── info_bar.py # 상단 인포바 (10%)
│ │ ├── status_bar.py # 하단 상태바 (10%)
│ │ ├── content_panel.py # 중앙 컨텐츠 (80%)
│ │ ├── section_panel.py # 왼쪽 섹션 패널 (70%)
│ │ └── todo_panel.py # 오른쪽 Todo 패널 (30%)
│ │
│ ├── sections/ # 섹션별 UI
│ │ ├── __init__.py
│ │ ├── instruction_section.py # 지시 섹션
│ │ ├── fault_section.py # 고장 섹션
│ │ ├── work_section.py # 작업 섹션
│ │ └── misc_section.py # 기타 섹션
│ │
│ ├── dialogs/ # 다이얼로그
│ │ ├── __init__.py
│ │ ├── login_dialog.py # 로그인 다이얼로그
│ │ ├── settings_dialog.py # 설정 다이얼로그
│ │ ├── user_management_dialog.py # 사용자 관리
│ │ ├── input_dialog.py # 입력 다이얼로그
│ │ ├── train_input_dialog.py # 편성 입력 다이얼로그
│ │ ├── todo_input_dialog.py # Todo 입력 다이얼로그
│ │ └── memo_input_dialog.py # 메모 입력 다이얼로그
│ │
│ └── widgets/ # Todo/메모 관련 위젯
│ ├── __init__.py
│ ├── daily_inspection.py # 일상검수 편성 위젯
│ ├── todo_list.py # 할일 목록 위젯
│ └── memo_widget.py # 메모 위젯
└── utils/ # 유틸리티
├── __init__.py
├── helpers.py # 헬퍼 함수
├── validators.py # 유효성 검사
├── formatters.py # 포맷터
└── common_methods.py # 공통 메서드 (편성 팝업 등)
```
---
## 3. 모듈 의존성 다이어그램
```
┌─────────────────────────────────────────────────────────────────────────┐
│ main.py │
│ (Application Entry) │
└──────────────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ MainWindow (UI) │
│ ui/main_window.py │
└──────┬───────────────────────────┬──────────────────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌─────────────────────────────────────────────────┐
│ Services │ │ UI Modules │
│ ────────── │ │ ───────────────────────────────────────────── │
│ auth_service │◄────────┤ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ weather_srv │ │ │ info_bar │ │ sections │ │ todo_panel │ │
│ update_srv │ │ └──────────┘ └──────────┘ └──────────────┘ │
│ backup_srv │ │ │ │ │ │
└──────┬───────┘ │ ▼ ▼ ▼ │
│ │ ┌─────────────────────────────────────────┐ │
│ │ │ Base Components │ │
│ │ │ base_widget, base_section, base_table │ │
│ │ └─────────────────────────────────────────┘ │
│ └─────────────────────────┬───────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Core Module │
│ config, logger, constants, signals │
└──────────────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Database Module │
│ db_manager, models, crud, sync │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 4. UI 레이아웃 구조
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Title Bar │
│ [앱 아이콘] 전동차 인수인계 시스템 [─][□][X] │
├─────────────────────────────────────────────────────────────────────────┤
│ Menu Bar │
│ [파일] [편집] [보기] [설정] [도움말] │
├─────────────────────────────────────────────────────────────────────────┤
│ INFO BAR (10%) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────────┐ │
│ │ 2026.01.03 │ A팀 │ 주간 │ 팀변경 │ 🌤 서울 -3°C 맑음 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └──────────────────┘ │
├─────────────────────────────────────────────────────────────────────────┤
│ CONTENT AREA (80%) │
│ ┌───────────────────────────────────┬─────────────────────────────┐ │
│ │ SECTION PANEL (70%) │ TODO PANEL (30%) │◄──┼── Splitter
│ │ ┌───────────────────────────┐ │ ┌───────────────────────┐ │ │
│ │ │ [지시][고장][작업][기타] │ │ │ 일상검수 (30%) │ │ │
│ │ │ ─────────────────────────│ │ │ ┌─────┬─────────────┐│ │ │
│ │ │ 날짜 │팀│내용 │확인 │ │ │ │주간 │①②③④⑤ ││ │ │
│ │ │──────┼──┼────────┼─────│ │ │ │야간 │①②③④⑤ ││ │ │
│ │ │01/03 │A │지시내용1│✓✓✓ │ │ │ └─────┴─────────────┘│ │ │
│ │ │01/02 │B │지시내용2│✓✓✗ │ │ ├───────────────────────┤ │ │
│ │ │... │ │ │ │ │ │ 할일 목록 (35%) │ │ │
│ │ └───────────────────────────┘ │ │ ┌─────────────────┐ │ │ │
│ │ │ │ │□ 점검 사항 1 │ │ │ │
│ │ │ │ │☑ 점검 사항 2 │ │ │ │
│ │ │ │ └─────────────────┘ │ │ │
│ │ │ ├───────────────────────┤ │ │
│ │ │ │ 메모 (35%) │ │ │
│ │ │ │ ┌─────────────────┐ │ │ │
│ │ │ │ │ 메모 내용... │ │ │ │
│ │ │ │ └─────────────────┘ │ │ │
│ │ │ └───────────────────────┘ │ │
│ └───────────────────────────────────┴─────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────┤
│ STATUS BAR (10%) │
│ [접속자: 홍길동(검수팀)] [DB상태: 정상] [마지막 동기화: 10:30] [v1.0.0] │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 5. 데이터베이스 스키마
### 5.1 사용자 테이블 (users)
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
department TEXT NOT NULL, -- 검수팀, 운전팀, 차량팀 등
role TEXT NOT NULL, -- admin, editor, viewer
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### 5.2 팀 테이블 (teams)
```sql
CREATE TABLE teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- A팀, B팀, C팀, D팀
shift_type TEXT, -- 주간, 야간
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### 5.3 지시 섹션 (instructions)
```sql
CREATE TABLE instructions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_date DATE NOT NULL,
created_team TEXT NOT NULL,
instructor TEXT, -- 지시자
instruction_content TEXT NOT NULL, -- 지시내용
instruction_date DATE, -- 지시일자
is_continuous BOOLEAN DEFAULT 0, -- 지속여부
team_confirmations TEXT, -- JSON: {"A": true, "B": false, ...}
is_completed BOOLEAN DEFAULT 0,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
```
### 5.4 고장 섹션 (faults)
```sql
CREATE TABLE faults (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_date DATE NOT NULL,
created_team TEXT NOT NULL,
occurrence_date DATE, -- 발생일자
train_number TEXT, -- 편성
car_number TEXT, -- 호차
fault_code TEXT, -- 고장코드
device_category TEXT, -- 장치분류
occurrence_station TEXT, -- 발생역
occurrence_time TIME, -- 발생시간
fault_content TEXT, -- 고장내용
action_content TEXT, -- 조치내용
action_team TEXT, -- 조치팀
team_confirmations TEXT, -- JSON
is_completed BOOLEAN DEFAULT 0,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
```
### 5.5 작업 섹션 (works)
```sql
CREATE TABLE works (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_date DATE NOT NULL,
created_team TEXT NOT NULL,
work_date DATE, -- 작업일정
work_entity TEXT, -- 작업주체
target_train TEXT, -- 대상편성
target_device TEXT, -- 대상기기
work_content TEXT, -- 작업내용
remarks TEXT, -- 특이사항
team_confirmations TEXT, -- JSON
is_completed BOOLEAN DEFAULT 0,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
```
### 5.6 기타 섹션 (miscs)
```sql
CREATE TABLE miscs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_date DATE NOT NULL,
created_team TEXT NOT NULL,
reporter TEXT, -- 전달자
report_content TEXT, -- 전달내용
remarks TEXT, -- 특이사항
related_document TEXT, -- 관련문서
team_confirmations TEXT, -- JSON
is_completed BOOLEAN DEFAULT 0,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
```
### 5.7 일상검수 (daily_inspections)
```sql
CREATE TABLE daily_inspections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inspection_date DATE NOT NULL,
shift_type TEXT NOT NULL, -- 주간, 야간
slot_number INTEGER NOT NULL, -- 1~5
train_number TEXT, -- 편성번호
cleaning_type TEXT, -- 없음, 중청소, 대청소
has_work BOOLEAN DEFAULT 0, -- 작업 여부
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
```
### 5.8 할일 목록 (todos)
```sql
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
todo_date DATE NOT NULL,
target_train TEXT, -- 대상편성
schedule TEXT, -- 일정
content TEXT NOT NULL, -- 내용
is_completed BOOLEAN DEFAULT 0,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
```
### 5.9 메모 (memos)
```sql
CREATE TABLE memos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memo_date DATE NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
```
### 5.10 설정 (settings)
```sql
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
## 6. 클래스 상속 구조
### 6.1 위젯 상속 구조
```
QWidget
└── BaseWidget
├── InfoBar
├── StatusBar
├── ContentPanel
│ ├── SectionPanel
│ └── TodoPanel
└── BaseSection
├── InstructionSection
├── FaultSection
├── WorkSection
└── MiscSection
```
### 6.2 다이얼로그 상속 구조
```
QDialog
└── BaseDialog
├── LoginDialog
├── SettingsDialog
├── UserManagementDialog
└── InputDialog
├── TrainInputDialog
├── TodoInputDialog
└── MemoInputDialog
```
### 6.3 테이블 상속 구조
```
QTableWidget
└── BaseTable
└── SectionTable
├── InstructionTable
├── FaultTable
├── WorkTable
└── MiscTable
```
---
## 7. 권한 시스템
### 7.1 부서별 권한
| 부서 | 역할 | 권한 |
|------|------|------|
| 검수팀 | admin | 모든 CRUD 가능, 사용자 관리, 설정 변경 |
| 기타 부서 | viewer | 조회만 가능 |
### 7.2 권한 확인 플로우
```
사용자 로그인
권한 확인 (department, role)
UI 요소 활성화/비활성화
DB 작업 시 권한 재확인
```
---
## 8. 공통 메서드 (편성 팝업 등)
### 8.1 TrainInfoMixin
```python
class TrainInfoMixin:
"""편성 정보 관련 공통 기능을 제공하는 Mixin 클래스"""
def show_train_popup(self, train_number: str, position: QPoint):
"""마우스 호버 시 편성의 최근 고장 목록을 팝업으로 표시"""
pass
def get_recent_faults(self, train_number: str, limit: int = 10) -> List[dict]:
"""특정 편성의 최근 고장 목록 조회"""
pass
def open_train_detail(self, train_number: str):
"""편성 상세 정보 다이얼로그 열기"""
pass
```
---
## 9. 업데이트 시스템
### 9.1 업데이트 플로우
```
앱 시작
버전 확인 (주기적: 1시간마다)
새 버전 발견 시 알림
사용자 확인 후 다운로드
앱 재시작 후 업데이트 적용
```
---
## 10. 로깅 시스템
### 10.1 로그 레벨
- DEBUG: 상세 디버그 정보
- INFO: 일반 정보
- WARNING: 경고
- ERROR: 오류
- CRITICAL: 심각한 오류
### 10.2 로그 저장
- 파일: logs/app_YYYYMMDD.log
- 최대 보관: 30일
- 로테이션: 일별
---
## 11. 테마 시스템
### 11.1 지원 테마
- Light Theme: 밝은 테마
- Dark Theme: 어두운 테마
### 11.2 커스텀 스타일
- GmarketSans 폰트 적용
- 현대적인 둥근 모서리
- 그라데이션 효과
- 애니메이션 전환

45
HandoverSystem.spec Normal file
View File

@ -0,0 +1,45 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[('assets', 'assets'), ('config.ini', '.'), ('database', 'database')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='HandoverSystem',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['assets\\icons\\app_icon.ico'],
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='HandoverSystem',
)

BIN
assets/icons/app_icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -0,0 +1,350 @@
/*
* 다크 테마 스타일시트
* 전동차 업무 인수인계 시스템
*/
/* ========================================
기본 위젯
======================================== */
QWidget {
background-color: #0f172a;
color: #f8fafc;
font-family: 'GmarketSans', sans-serif;
}
QMainWindow {
background-color: #0f172a;
}
/* ========================================
라벨
======================================== */
QLabel {
color: #f8fafc;
}
QLabel[class="secondary"] {
color: #94a3b8;
}
QLabel[class="title"] {
font-size: 18px;
font-weight: bold;
}
/* ========================================
버튼
======================================== */
QPushButton {
background-color: #334155;
color: #f8fafc;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-weight: 500;
}
QPushButton:hover {
background-color: #475569;
}
QPushButton:pressed {
background-color: #1e293b;
}
QPushButton:disabled {
background-color: #1e293b;
color: #64748b;
}
QPushButton[class="primary"] {
background-color: #3b82f6;
color: white;
}
QPushButton[class="primary"]:hover {
background-color: #2563eb;
}
QPushButton[class="danger"] {
background-color: #ef4444;
color: white;
}
QPushButton[class="danger"]:hover {
background-color: #dc2626;
}
/* ========================================
입력 필드
======================================== */
QLineEdit {
background-color: #1e293b;
color: #f8fafc;
border: 2px solid #334155;
border-radius: 8px;
padding: 10px 14px;
selection-background-color: #3b82f6;
}
QLineEdit:focus {
border-color: #3b82f6;
}
QLineEdit:disabled {
background-color: #334155;
color: #64748b;
}
QTextEdit {
background-color: #1e293b;
color: #f8fafc;
border: 2px solid #334155;
border-radius: 8px;
padding: 10px;
selection-background-color: #3b82f6;
}
QTextEdit:focus {
border-color: #3b82f6;
}
/* ========================================
콤보박스
======================================== */
QComboBox {
background-color: #1e293b;
color: #f8fafc;
border: 2px solid #334155;
border-radius: 8px;
padding: 10px 14px;
min-height: 20px;
}
QComboBox:hover {
border-color: #3b82f6;
}
QComboBox::drop-down {
border: none;
width: 30px;
}
QComboBox::down-arrow {
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #f8fafc;
margin-right: 10px;
}
QComboBox QAbstractItemView {
background-color: #1e293b;
color: #f8fafc;
border: 1px solid #334155;
border-radius: 8px;
selection-background-color: #3b82f6;
}
/* ========================================
테이블
======================================== */
QTableWidget {
background-color: #0f172a;
color: #f8fafc;
border: 1px solid #334155;
border-radius: 8px;
gridline-color: transparent;
}
QTableWidget::item {
padding: 8px;
border-bottom: 1px solid #334155;
}
QTableWidget::item:selected {
background-color: #3b82f6;
color: white;
}
QTableWidget::item:hover {
background-color: #1e3a5f;
}
QHeaderView::section {
background-color: #334155;
color: #f8fafc;
padding: 12px 8px;
border: none;
border-bottom: 2px solid #334155;
font-weight: bold;
}
/* ========================================
스크롤바
======================================== */
QScrollBar:vertical {
background-color: #1e293b;
width: 10px;
border-radius: 5px;
}
QScrollBar::handle:vertical {
background-color: #475569;
border-radius: 5px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover {
background-color: #3b82f6;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical {
height: 0;
}
QScrollBar:horizontal {
background-color: #1e293b;
height: 10px;
border-radius: 5px;
}
QScrollBar::handle:horizontal {
background-color: #475569;
border-radius: 5px;
min-width: 20px;
}
QScrollBar::handle:horizontal:hover {
background-color: #3b82f6;
}
/* ========================================
탭 위젯
======================================== */
QTabWidget::pane {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
border-top-left-radius: 0;
}
QTabBar::tab {
background-color: #1e293b;
color: #94a3b8;
border: 1px solid #334155;
border-bottom: none;
padding: 12px 24px;
margin-right: 4px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
QTabBar::tab:selected {
background-color: #3b82f6;
color: white;
border-color: #3b82f6;
}
QTabBar::tab:hover:!selected {
background-color: #334155;
}
/* ========================================
메뉴
======================================== */
QMenuBar {
background-color: #1e293b;
color: #f8fafc;
border-bottom: 1px solid #334155;
}
QMenuBar::item {
padding: 8px 16px;
border-radius: 4px;
}
QMenuBar::item:selected {
background-color: #334155;
}
QMenu {
background-color: #1e293b;
color: #f8fafc;
border: 1px solid #334155;
border-radius: 8px;
padding: 4px;
}
QMenu::item {
padding: 8px 24px;
border-radius: 4px;
}
QMenu::item:selected {
background-color: #334155;
}
QMenu::separator {
height: 1px;
background-color: #334155;
margin: 4px 8px;
}
/* ========================================
체크박스
======================================== */
QCheckBox {
color: #f8fafc;
spacing: 8px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 4px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #64748b;
background-color: transparent;
}
QCheckBox::indicator:checked {
border: 2px solid #22c55e;
background-color: #22c55e;
}
/* ========================================
프레임
======================================== */
QFrame[frameShape="4"],
QFrame[frameShape="5"] {
color: #334155;
}
/* ========================================
툴팁
======================================== */
QToolTip {
background-color: #1e293b;
color: #f8fafc;
border: 1px solid #334155;
border-radius: 6px;
padding: 8px;
}

View File

@ -0,0 +1,350 @@
/*
* 라이트 테마 스타일시트
* 전동차 업무 인수인계 시스템
*/
/* ========================================
기본 위젯
======================================== */
QWidget {
background-color: #f8fafc;
color: #1e293b;
font-family: 'GmarketSans', sans-serif;
}
QMainWindow {
background-color: #f8fafc;
}
/* ========================================
라벨
======================================== */
QLabel {
color: #1e293b;
}
QLabel[class="secondary"] {
color: #64748b;
}
QLabel[class="title"] {
font-size: 18px;
font-weight: bold;
}
/* ========================================
버튼
======================================== */
QPushButton {
background-color: #e2e8f0;
color: #1e293b;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-weight: 500;
}
QPushButton:hover {
background-color: #cbd5e1;
}
QPushButton:pressed {
background-color: #94a3b8;
}
QPushButton:disabled {
background-color: #f1f5f9;
color: #94a3b8;
}
QPushButton[class="primary"] {
background-color: #3b82f6;
color: white;
}
QPushButton[class="primary"]:hover {
background-color: #2563eb;
}
QPushButton[class="danger"] {
background-color: #ef4444;
color: white;
}
QPushButton[class="danger"]:hover {
background-color: #dc2626;
}
/* ========================================
입력 필드
======================================== */
QLineEdit {
background-color: #ffffff;
color: #1e293b;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 10px 14px;
selection-background-color: #3b82f6;
}
QLineEdit:focus {
border-color: #3b82f6;
}
QLineEdit:disabled {
background-color: #f1f5f9;
color: #94a3b8;
}
QTextEdit {
background-color: #ffffff;
color: #1e293b;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
selection-background-color: #3b82f6;
}
QTextEdit:focus {
border-color: #3b82f6;
}
/* ========================================
콤보박스
======================================== */
QComboBox {
background-color: #ffffff;
color: #1e293b;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 10px 14px;
min-height: 20px;
}
QComboBox:hover {
border-color: #3b82f6;
}
QComboBox::drop-down {
border: none;
width: 30px;
}
QComboBox::down-arrow {
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #1e293b;
margin-right: 10px;
}
QComboBox QAbstractItemView {
background-color: #ffffff;
color: #1e293b;
border: 1px solid #e2e8f0;
border-radius: 8px;
selection-background-color: #3b82f6;
}
/* ========================================
테이블
======================================== */
QTableWidget {
background-color: #ffffff;
color: #1e293b;
border: 1px solid #e2e8f0;
border-radius: 8px;
gridline-color: transparent;
}
QTableWidget::item {
padding: 8px;
border-bottom: 1px solid #e2e8f0;
}
QTableWidget::item:selected {
background-color: #3b82f6;
color: white;
}
QTableWidget::item:hover {
background-color: #dbeafe;
}
QHeaderView::section {
background-color: #e2e8f0;
color: #1e293b;
padding: 12px 8px;
border: none;
border-bottom: 2px solid #e2e8f0;
font-weight: bold;
}
/* ========================================
스크롤바
======================================== */
QScrollBar:vertical {
background-color: #f8fafc;
width: 10px;
border-radius: 5px;
}
QScrollBar::handle:vertical {
background-color: #cbd5e1;
border-radius: 5px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover {
background-color: #3b82f6;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical {
height: 0;
}
QScrollBar:horizontal {
background-color: #f8fafc;
height: 10px;
border-radius: 5px;
}
QScrollBar::handle:horizontal {
background-color: #cbd5e1;
border-radius: 5px;
min-width: 20px;
}
QScrollBar::handle:horizontal:hover {
background-color: #3b82f6;
}
/* ========================================
탭 위젯
======================================== */
QTabWidget::pane {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-top-left-radius: 0;
}
QTabBar::tab {
background-color: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
border-bottom: none;
padding: 12px 24px;
margin-right: 4px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
QTabBar::tab:selected {
background-color: #3b82f6;
color: white;
border-color: #3b82f6;
}
QTabBar::tab:hover:!selected {
background-color: #e2e8f0;
}
/* ========================================
메뉴
======================================== */
QMenuBar {
background-color: #ffffff;
color: #1e293b;
border-bottom: 1px solid #e2e8f0;
}
QMenuBar::item {
padding: 8px 16px;
border-radius: 4px;
}
QMenuBar::item:selected {
background-color: #f1f5f9;
}
QMenu {
background-color: #ffffff;
color: #1e293b;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 4px;
}
QMenu::item {
padding: 8px 24px;
border-radius: 4px;
}
QMenu::item:selected {
background-color: #f1f5f9;
}
QMenu::separator {
height: 1px;
background-color: #e2e8f0;
margin: 4px 8px;
}
/* ========================================
체크박스
======================================== */
QCheckBox {
color: #1e293b;
spacing: 8px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 4px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #94a3b8;
background-color: transparent;
}
QCheckBox::indicator:checked {
border: 2px solid #22c55e;
background-color: #22c55e;
}
/* ========================================
프레임
======================================== */
QFrame[frameShape="4"],
QFrame[frameShape="5"] {
color: #e2e8f0;
}
/* ========================================
툴팁
======================================== */
QToolTip {
background-color: #ffffff;
color: #1e293b;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
}

159
config.ini Normal file

File diff suppressed because one or more lines are too long

12
convert_icon.py Normal file
View File

@ -0,0 +1,12 @@
from PIL import Image
import os
png_path = "C:/Users/Administrator/.gemini/antigravity/brain/10e61a12-dee5-4d3c-8d60-b27a2836a655/app_icon_1768574231881.png"
ico_path = "d:/py_train/handover/assets/icons/app_icon.ico"
# Ensure directory exists
os.makedirs(os.path.dirname(ico_path), exist_ok=True)
img = Image.open(png_path)
img.save(ico_path, format='ICO', sizes=[(256, 256)])
print(f"Icon saved to {ico_path}")

40
core/__init__.py Normal file
View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""
Core 모듈 패키지
핵심 기능을 제공하는 모듈들의 집합
패키지는 다음을 포함합니다:
- constants: 상수 정의
- config: 설정 관리
- settings_manager: 설정 DB 관리 (SQLite)
- logger: 로깅 시스템
- signals: 전역 시그널
- exceptions: 커스텀 예외
"""
from .constants import *
from .config import ConfigManager
from .settings_manager import (
SettingsManager, get_settings_manager,
FieldSetting, TrainInfo, StationInfo,
ManufacturerInfo, FaultCodeInfo
)
from .logger import setup_logger, get_logger
from .signals import GlobalSignals
from .exceptions import *
__all__ = [
'ConfigManager',
'SettingsManager',
'get_settings_manager',
'FieldSetting',
'TrainInfo',
'StationInfo',
'ManufacturerInfo',
'FaultCodeInfo',
'setup_logger',
'get_logger',
'GlobalSignals',
]

909
core/config.py Normal file
View File

@ -0,0 +1,909 @@
# -*- coding: utf-8 -*-
"""
설정 관리 모듈
애플리케이션의 설정을 관리합니다.
모듈은 다음 기능을 제공합니다:
- 설정 파일 읽기/쓰기
- 기본 설정 관리
- 런타임 설정 변경
- 설정 유효성 검사
"""
import os
import json
import configparser
from pathlib import Path
from typing import Any, Dict, Optional, Union, List
from dataclasses import dataclass, field, asdict
from .constants import (
CONFIG_FILE,
DATA_DIR,
LAYOUT_RATIOS,
FONT_FAMILY,
FONT_SIZES,
UI_FONT_SETTINGS,
)
from .logger import get_logger
# 로거 설정
logger = get_logger(__name__)
# ============================================================================
# 설정 데이터 클래스
# ============================================================================
@dataclass
class AppSettings:
"""애플리케이션 일반 설정"""
language: str = "ko"
theme: str = "dark"
font_family: str = FONT_FAMILY
font_size: int = FONT_SIZES["body"]
auto_save: bool = True
auto_save_interval: int = 30 # 초
check_updates: bool = True
update_check_interval: int = 3600 # 초
start_minimized: bool = False
minimize_to_tray: bool = True
show_notifications: bool = True
@dataclass
class LayoutSettings:
"""레이아웃 설정"""
info_bar_ratio: float = LAYOUT_RATIOS["info_bar"]
content_area_ratio: float = LAYOUT_RATIOS["content_area"]
status_bar_ratio: float = LAYOUT_RATIOS["status_bar"]
section_panel_ratio: float = LAYOUT_RATIOS["section_panel"]
todo_panel_ratio: float = LAYOUT_RATIOS["todo_panel"]
daily_inspection_ratio: float = LAYOUT_RATIOS["daily_inspection"]
todo_list_ratio: float = LAYOUT_RATIOS["todo_list"]
memo_ratio: float = LAYOUT_RATIOS["memo"]
window_width: int = 1600
window_height: int = 900
window_x: int = -1 # -1은 화면 중앙
window_y: int = -1
@dataclass
class DatabaseSettings:
"""데이터베이스 설정"""
db_path: str = str(DATA_DIR / "handover.db")
backup_enabled: bool = True
backup_interval: int = 86400 # 24시간
backup_count: int = 7
sync_enabled: bool = False
sync_url: str = ""
sync_key: str = ""
sync_interval: int = 300 # 5분
@dataclass
class WeatherSettings:
"""날씨 설정"""
enabled: bool = True
api_key: str = ""
location_lat: float = 35.1796 # 부산 기본값
location_lon: float = 129.0756
location_name: str = "부산"
update_interval: int = 1800 # 30분
forecast_unit: str = "1시간 단위" # 1시간 단위 또는 3시간 단위
@dataclass
class FieldSetting:
"""필드 설정 데이터 클래스"""
name: str = ""
visible: bool = True
width: int = 100
display_format: Optional[str] = None # "full", "short", "month_day" 등
@dataclass
class UserSettings:
"""사용자 설정 (런타임)"""
current_team: str = "1팀"
current_shift: str = "주간"
last_user_id: int = 0
remember_login: bool = True
# 필드 설정: 팀별로 섹션별 필드 설정 저장
# 형식: {"팀명": {"섹션명": [FieldSetting, ...], ...}, ...}
field_settings: Dict[str, Dict[str, List[Dict[str, Any]]]] = field(default_factory=dict)
@dataclass
class TrainSettings:
"""편성 설정 (A: 구형, B: 신형)"""
# 기본값: 홀수=A(구형), 짝수=B(신형)
# 신평 차량: 6,7,8,9,13,16,32~48
train_1_type: str = "A"
train_2_type: str = "B"
train_3_type: str = "A"
train_4_type: str = "B"
train_5_type: str = "A"
train_6_type: str = "B"
train_7_type: str = "B"
train_8_type: str = "B"
train_9_type: str = "B"
train_10_type: str = "B"
train_11_type: str = "A"
train_12_type: str = "B"
train_13_type: str = "B"
train_14_type: str = "B"
train_15_type: str = "A"
train_16_type: str = "B"
train_17_type: str = "A"
train_18_type: str = "B"
train_19_type: str = "A"
train_20_type: str = "B"
train_21_type: str = "A"
train_22_type: str = "B"
train_23_type: str = "A"
train_24_type: str = "B"
train_25_type: str = "A"
train_26_type: str = "B"
train_27_type: str = "A"
train_28_type: str = "B"
train_29_type: str = "A"
train_30_type: str = "B"
train_31_type: str = "A"
train_32_type: str = "B"
train_33_type: str = "B"
train_34_type: str = "B"
train_35_type: str = "B"
train_36_type: str = "B"
train_37_type: str = "B"
train_38_type: str = "B"
train_39_type: str = "B"
train_40_type: str = "B"
train_41_type: str = "B"
train_42_type: str = "B"
train_43_type: str = "B"
train_44_type: str = "B"
train_45_type: str = "B"
train_46_type: str = "B"
train_47_type: str = "B"
train_48_type: str = "B"
train_49_type: str = "A"
train_50_type: str = "B"
train_51_type: str = "A"
@dataclass
class UIFontSettings:
"""UI 폰트 설정"""
# 인포바
info_bar_title_family: str = FONT_FAMILY
info_bar_title_size: int = 16
info_bar_title_weight: str = "bold"
info_bar_content_family: str = FONT_FAMILY
info_bar_content_size: int = 14
info_bar_content_weight: str = "normal"
# 섹션
section_title_family: str = FONT_FAMILY
section_title_size: int = 16
section_title_weight: str = "bold"
section_header_family: str = FONT_FAMILY
section_header_size: int = 13
section_header_weight: str = "bold"
section_content_family: str = FONT_FAMILY
section_content_size: int = 13
section_content_weight: str = "normal"
# 할일
todo_title_family: str = FONT_FAMILY
todo_title_size: int = 14
todo_title_weight: str = "bold"
todo_content_family: str = FONT_FAMILY
todo_content_size: int = 13
todo_content_weight: str = "normal"
# 메모
memo_title_family: str = FONT_FAMILY
memo_title_size: int = 14
memo_title_weight: str = "bold"
memo_content_family: str = FONT_FAMILY
memo_content_size: int = 13
memo_content_weight: str = "normal"
# 일상검수
daily_title_family: str = FONT_FAMILY
daily_title_size: int = 14
daily_title_weight: str = "bold"
daily_content_family: str = FONT_FAMILY
daily_content_size: int = 13
daily_content_weight: str = "normal"
daily_train_family: str = FONT_FAMILY
daily_train_size: int = 15
daily_train_weight: str = "bold"
# 상태바
status_content_family: str = FONT_FAMILY
status_content_size: int = 12
status_content_weight: str = "normal"
# 다이얼로그
dialog_title_family: str = FONT_FAMILY
dialog_title_size: int = 14
dialog_title_weight: str = "bold"
dialog_label_family: str = FONT_FAMILY
dialog_label_size: int = 12
dialog_label_weight: str = "normal"
dialog_input_family: str = FONT_FAMILY
dialog_input_size: int = 12
dialog_input_weight: str = "normal"
dialog_button_family: str = FONT_FAMILY
dialog_button_size: int = 12
dialog_button_weight: str = "medium"
@dataclass
class FieldSetting:
"""필드 설정 데이터 클래스"""
name: str
visible: bool = True
width: int = 100
display_format: Optional[str] = None # "full", "short", "month_day" 등
@dataclass
class AllSettings:
"""모든 설정을 통합하는 클래스"""
app: AppSettings = field(default_factory=AppSettings)
layout: LayoutSettings = field(default_factory=LayoutSettings)
database: DatabaseSettings = field(default_factory=DatabaseSettings)
weather: WeatherSettings = field(default_factory=WeatherSettings)
user: UserSettings = field(default_factory=UserSettings)
ui_font: UIFontSettings = field(default_factory=UIFontSettings)
train: TrainSettings = field(default_factory=TrainSettings)
# ============================================================================
# 설정 관리자 클래스
# ============================================================================
class ConfigManager:
"""
설정 관리자 클래스
싱글톤 패턴을 사용하여 애플리케이션 전역에서 하나의 인스턴스만 사용합니다.
설정 파일의 읽기/쓰기 런타임 설정 변경을 담당합니다.
Attributes:
config_path: 설정 파일 경로
settings: 현재 설정 객체
Examples:
>>> config = ConfigManager()
>>> config.get('app', 'theme')
'dark'
>>> config.set('app', 'theme', 'light')
>>> config.save()
"""
_instance: Optional['ConfigManager'] = None
def __new__(cls, config_path: Path = None):
"""싱글톤 패턴 구현"""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, config_path: Path = None):
"""
설정 관리자 초기화
Args:
config_path: 설정 파일 경로 (기본값: CONFIG_FILE)
"""
# 이미 초기화된 경우 건너뛰기
if self._initialized:
return
self.config_path = config_path or CONFIG_FILE
self.settings = AllSettings()
self._parser = configparser.ConfigParser()
# 필드 설정 파일 경로 (레거시, 마이그레이션용)
self.field_settings_file = DATA_DIR / "field_settings.json"
# SettingsManager 인스턴스 (지연 로딩)
self._settings_manager = None
# 설정 파일 로드
self.load()
# 필드 설정 로드 (JSON -> SQLite 마이그레이션 포함)
self._load_field_settings()
self._initialized = True
logger.info(f"설정 관리자 초기화 완료: {self.config_path}")
def load(self) -> bool:
"""
설정 파일에서 설정을 로드합니다.
Returns:
로드 성공 여부
"""
try:
if self.config_path.exists():
self._parser.read(self.config_path, encoding='utf-8')
self._load_settings_from_parser()
logger.info("설정 파일 로드 완료")
return True
else:
logger.warning("설정 파일이 없습니다. 기본 설정을 사용합니다.")
self._create_default_config()
return False
except Exception as e:
logger.error(f"설정 파일 로드 실패: {e}")
self._create_default_config()
return False
def save(self) -> bool:
"""
현재 설정을 파일에 저장합니다.
Returns:
저장 성공 여부
"""
try:
self._save_settings_to_parser()
# 부모 디렉토리 생성
self.config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.config_path, 'w', encoding='utf-8') as f:
self._parser.write(f)
logger.info("설정 파일 저장 완료")
return True
except Exception as e:
logger.error(f"설정 파일 저장 실패: {e}")
return False
def get(self, section: str, key: str, default: Any = None) -> Any:
"""
설정 값을 가져옵니다.
Args:
section: 설정 섹션 (app, layout, database, weather, user)
key: 설정
default: 기본값
Returns:
설정
Examples:
>>> config.get('app', 'theme')
'dark'
"""
try:
section_obj = getattr(self.settings, section, None)
if section_obj is None:
logger.warning(f"존재하지 않는 섹션: {section}")
return default
value = getattr(section_obj, key, default)
return value
except Exception as e:
logger.error(f"설정 값 가져오기 실패: {section}.{key} - {e}")
return default
def set(self, section: str, key: str, value: Any) -> bool:
"""
설정 값을 변경합니다.
Args:
section: 설정 섹션
key: 설정
value: 새로운
Returns:
변경 성공 여부
Examples:
>>> config.set('app', 'theme', 'light')
True
"""
try:
section_obj = getattr(self.settings, section, None)
if section_obj is None:
logger.warning(f"존재하지 않는 섹션: {section}")
return False
if not hasattr(section_obj, key):
logger.warning(f"존재하지 않는 키: {section}.{key}")
return False
setattr(section_obj, key, value)
logger.debug(f"설정 변경: {section}.{key} = {value}")
return True
except Exception as e:
logger.error(f"설정 값 변경 실패: {section}.{key} - {e}")
return False
def get_section(self, section: str) -> Optional[Dict[str, Any]]:
"""
설정 섹션 전체를 딕셔너리로 반환합니다.
Args:
section: 설정 섹션
Returns:
섹션 설정 딕셔너리
"""
try:
section_obj = getattr(self.settings, section, None)
if section_obj is None:
return None
return asdict(section_obj)
except Exception as e:
logger.error(f"섹션 가져오기 실패: {section} - {e}")
return None
def reset_to_default(self, section: str = None):
"""
설정을 기본값으로 초기화합니다.
Args:
section: 초기화할 섹션 (None이면 전체 초기화)
"""
if section:
if section == 'app':
self.settings.app = AppSettings()
elif section == 'layout':
self.settings.layout = LayoutSettings()
elif section == 'database':
self.settings.database = DatabaseSettings()
elif section == 'weather':
self.settings.weather = WeatherSettings()
elif section == 'user':
self.settings.user = UserSettings()
elif section == 'ui_font':
self.settings.ui_font = UIFontSettings()
elif section == 'train':
self.settings.train = TrainSettings()
logger.info(f"섹션 '{section}' 기본값으로 초기화")
else:
self.settings = AllSettings()
logger.info("전체 설정 기본값으로 초기화")
def _load_settings_from_parser(self):
"""파서에서 설정 객체로 로드"""
for section_name in ['app', 'layout', 'database', 'weather', 'user', 'ui_font', 'train']:
if self._parser.has_section(section_name):
section_obj = getattr(self.settings, section_name)
for key in self._parser.options(section_name):
if hasattr(section_obj, key):
raw_value = self._parser.get(section_name, key)
# 타입 변환
expected_type = type(getattr(section_obj, key))
converted_value = self._convert_value(raw_value, expected_type)
setattr(section_obj, key, converted_value)
def _save_settings_to_parser(self):
"""설정 객체에서 파서로 저장"""
for section_name in ['app', 'layout', 'database', 'weather', 'user', 'ui_font', 'train']:
if not self._parser.has_section(section_name):
self._parser.add_section(section_name)
section_obj = getattr(self.settings, section_name)
section_dict = asdict(section_obj)
for key, value in section_dict.items():
# 필드 설정은 JSON으로 저장
if key == 'field_settings':
self._parser.set(section_name, key, json.dumps(value, ensure_ascii=False))
else:
self._parser.set(section_name, key, str(value))
def _create_default_config(self):
"""기본 설정 파일 생성"""
self.settings = AllSettings()
self.save()
logger.info("기본 설정 파일 생성 완료")
@staticmethod
def _convert_value(value: str, target_type: type) -> Any:
"""
문자열 값을 대상 타입으로 변환합니다.
Args:
value: 변환할 문자열
target_type: 대상 타입
Returns:
변환된
"""
if target_type == bool:
return value.lower() in ('true', 'yes', '1', 'on')
elif target_type == int:
return int(value)
elif target_type == float:
return float(value)
else:
return value
# ========================================================================
# 편의 메서드
# ========================================================================
@property
def theme(self) -> str:
"""현재 테마 반환"""
return self.settings.app.theme
@theme.setter
def theme(self, value: str):
"""테마 설정"""
self.settings.app.theme = value
@property
def current_team(self) -> str:
"""현재 팀 반환"""
return self.settings.user.current_team
@current_team.setter
def current_team(self, value: str):
"""현재 팀 설정"""
self.settings.user.current_team = value
@property
def current_shift(self) -> str:
"""현재 근무 유형 반환"""
return self.settings.user.current_shift
@current_shift.setter
def current_shift(self, value: str):
"""현재 근무 유형 설정"""
self.settings.user.current_shift = value
def get_layout_sizes(self) -> Dict[str, float]:
"""레이아웃 크기 비율 반환"""
return asdict(self.settings.layout)
def get_ui_font(self, area: str, style: str) -> Dict[str, Any]:
"""
UI 영역별 폰트 설정 가져오기
Args:
area: 영역 (info_bar, section, todo, memo, daily, status, dialog)
style: 스타일 (title, content, header, label, input, button, train )
Returns:
{"family": str, "size": int, "weight": str}
Examples:
>>> config.get_ui_font("section", "title")
{"family": "GmarketSans", "size": 16, "weight": "bold"}
"""
ui_font = self.settings.ui_font
# 영역과 스타일 조합으로 속성 이름 생성
prefix = area.replace("_", "") # info_bar -> infobar
# 영역별 prefix 매핑
area_map = {
"info_bar": "info_bar",
"section": "section",
"todo": "todo",
"memo": "memo",
"daily": "daily",
"daily_inspection": "daily",
"status": "status",
"status_bar": "status",
"dialog": "dialog",
}
prefix = area_map.get(area, area)
# 속성 이름 생성
family_attr = f"{prefix}_{style}_family"
size_attr = f"{prefix}_{style}_size"
weight_attr = f"{prefix}_{style}_weight"
# 기본값
default = {"family": FONT_FAMILY, "size": 13, "weight": "normal"}
try:
return {
"family": getattr(ui_font, family_attr, default["family"]),
"size": getattr(ui_font, size_attr, default["size"]),
"weight": getattr(ui_font, weight_attr, default["weight"]),
}
except AttributeError:
logger.warning(f"UI 폰트 설정을 찾을 수 없음: {area}.{style}")
return default
def set_ui_font(self, area: str, style: str, family: str = None, size: int = None, weight: str = None):
"""
UI 영역별 폰트 설정 변경
Args:
area: 영역
style: 스타일
family: 폰트 패밀리 (None이면 변경 안함)
size: 폰트 크기 (None이면 변경 안함)
weight: 폰트 굵기 (None이면 변경 안함)
"""
ui_font = self.settings.ui_font
area_map = {
"info_bar": "info_bar",
"section": "section",
"todo": "todo",
"memo": "memo",
"daily": "daily",
"daily_inspection": "daily",
"status": "status",
"status_bar": "status",
"dialog": "dialog",
}
prefix = area_map.get(area, area)
if family is not None:
setattr(ui_font, f"{prefix}_{style}_family", family)
if size is not None:
setattr(ui_font, f"{prefix}_{style}_size", size)
if weight is not None:
setattr(ui_font, f"{prefix}_{style}_weight", weight)
logger.debug(f"UI 폰트 설정 변경: {area}.{style}")
@property
def ui_font_settings(self) -> 'UIFontSettings':
"""UI 폰트 설정 반환"""
return self.settings.ui_font
# ========================================================================
# 필드 설정 관리 메서드 (SettingsManager 사용)
# ========================================================================
def _get_settings_manager(self):
"""SettingsManager 인스턴스 반환 (지연 로딩)"""
if not hasattr(self, '_settings_manager') or self._settings_manager is None:
from .settings_manager import get_settings_manager
self._settings_manager = get_settings_manager()
return self._settings_manager
def _load_field_settings(self):
"""
필드 설정 로드 (레거시 JSON -> SQLite DB 마이그레이션)
기존 JSON 파일이 있으면 SQLite DB로 마이그레이션하고,
이후에는 DB에서 직접 로드합니다.
"""
try:
settings_mgr = self._get_settings_manager()
# 기존 JSON 파일 마이그레이션 체크
if self.field_settings_file.exists():
logger.info("기존 JSON 필드 설정을 SQLite DB로 마이그레이션합니다.")
self._migrate_json_to_db()
# 기존 section_field_settings.json 파일도 마이그레이션 체크
old_settings_file = DATA_DIR / "section_field_settings.json"
if old_settings_file.exists():
logger.info("기존 section_field_settings.json을 SQLite DB로 마이그레이션합니다.")
self._migrate_old_json_to_db(old_settings_file)
logger.info("필드 설정 로드 완료 (SQLite DB 사용)")
except Exception as e:
logger.error(f"필드 설정 로드 실패: {e}")
def _migrate_json_to_db(self):
"""JSON 파일을 SQLite DB로 마이그레이션"""
try:
with open(self.field_settings_file, 'r', encoding='utf-8') as f:
data = json.load(f)
settings_mgr = self._get_settings_manager()
from .settings_manager import FieldSetting as DBFieldSetting
for team, sections_data in data.items():
for section_name, fields_data in sections_data.items():
fields = []
for field_data in fields_data:
field = DBFieldSetting(
name=field_data.get('name', ''),
visible=field_data.get('visible', True),
width=field_data.get('width', 100),
display_format=field_data.get('display_format')
)
fields.append(field)
settings_mgr.save_field_settings(team, section_name, fields)
# 마이그레이션 완료 후 기존 파일 백업
backup_file = self.field_settings_file.with_suffix('.json.bak')
self.field_settings_file.rename(backup_file)
logger.info(f"JSON 파일을 SQLite DB로 마이그레이션 완료, 백업: {backup_file}")
except Exception as e:
logger.error(f"JSON 마이그레이션 실패: {e}")
def _migrate_old_json_to_db(self, old_file):
"""기존 형식의 JSON 파일을 SQLite DB로 마이그레이션"""
try:
with open(old_file, 'r', encoding='utf-8') as f:
old_data = json.load(f)
settings_mgr = self._get_settings_manager()
from .settings_manager import FieldSetting as DBFieldSetting
for team, team_data in old_data.items():
sections = team_data.get("sections", {})
for section_name, section_data in sections.items():
fields_data = section_data.get("fields", [])
fields = []
for field_data in fields_data:
field = DBFieldSetting(
name=field_data.get('name', ''),
visible=field_data.get('visible', True),
width=field_data.get('width', 100),
display_format=field_data.get('display_format')
)
fields.append(field)
settings_mgr.save_field_settings(team, section_name, fields)
# 마이그레이션 완료 후 기존 파일 백업
backup_file = old_file.with_suffix('.json.bak')
old_file.rename(backup_file)
logger.info(f"기존 JSON 파일을 SQLite DB로 마이그레이션 완료, 백업: {backup_file}")
except Exception as e:
logger.error(f"기존 JSON 마이그레이션 실패: {e}")
def save_field_settings(
self,
team: str,
section_name: str,
fields: List[FieldSetting]
):
"""
필드 설정 저장 (SQLite DB 사용)
Args:
team: 이름 (: "1팀")
section_name: 섹션 이름 (: "고장")
fields: 필드 설정 리스트
"""
try:
settings_mgr = self._get_settings_manager()
from .settings_manager import FieldSetting as DBFieldSetting
# FieldSetting -> DBFieldSetting 변환
db_fields = []
for f in fields:
db_field = DBFieldSetting(
name=f.name,
visible=f.visible,
width=f.width,
display_format=f.display_format
)
db_fields.append(db_field)
settings_mgr.save_field_settings(team, section_name, db_fields)
logger.info(f"필드 설정 저장 완료: {team} - {section_name}")
except Exception as e:
logger.error(f"필드 설정 저장 실패: {e}")
raise
def load_field_settings(
self,
team: str,
section_name: str
) -> Optional[List[FieldSetting]]:
"""
필드 설정 로드 (SQLite DB 사용)
Args:
team: 이름
section_name: 섹션 이름
Returns:
필드 설정 리스트 (없으면 None)
"""
try:
settings_mgr = self._get_settings_manager()
db_fields = settings_mgr.load_field_settings(team, section_name)
if not db_fields:
return None
# DBFieldSetting -> FieldSetting 변환
fields = []
for f in db_fields:
field = FieldSetting(
name=f.name,
visible=f.visible,
width=f.width,
display_format=f.display_format
)
fields.append(field)
return fields
except Exception as e:
logger.error(f"필드 설정 로드 실패: {e}")
return None
def get_field_setting(
self,
team: str,
section_name: str,
field_name: str
) -> Optional[FieldSetting]:
"""
특정 필드 설정 가져오기
Args:
team: 이름
section_name: 섹션 이름
field_name: 필드 이름
Returns:
필드 설정 (없으면 None)
"""
fields = self.load_field_settings(team, section_name)
if not fields:
return None
for field_setting in fields:
if field_setting.name == field_name:
return field_setting
return None
def apply_field_settings_to_fields(
self,
team: str,
section_name: str,
fields: List[Any] # FieldConfig 리스트
):
"""
저장된 설정을 필드에 적용
Args:
team: 이름
section_name: 섹션 이름
fields: FieldConfig 리스트
"""
settings_mgr = self._get_settings_manager()
settings_mgr.apply_field_settings_to_fields(team, section_name, fields)
logger.debug(f"필드 설정 적용 완료: {team} - {section_name}")
def reset_team_field_settings(self, team: str):
"""팀별 필드 설정 초기화"""
settings_mgr = self._get_settings_manager()
settings_mgr.reset_team_field_settings(team)
logger.info(f"팀 필드 설정 초기화: {team}")
def reset_all_field_settings(self):
"""모든 필드 설정 초기화"""
settings_mgr = self._get_settings_manager()
settings_mgr.reset_all_field_settings()
logger.info("모든 필드 설정 초기화 완료")
# ============================================================================
# 모듈 레벨 편의 함수
# ============================================================================
def get_config() -> ConfigManager:
"""
설정 관리자 인스턴스를 반환합니다.
Returns:
ConfigManager 인스턴스
"""
return ConfigManager()

369
core/constants.py Normal file
View File

@ -0,0 +1,369 @@
# -*- coding: utf-8 -*-
"""
상수 정의 모듈
애플리케이션 전역에서 사용되는 상수들을 정의합니다.
모듈은 다음을 포함합니다:
- 애플리케이션 정보
- 권한 관련 상수
- UI 관련 상수
- 데이터베이스 관련 상수
- 파일 경로 상수
"""
import os
from pathlib import Path
from typing import Dict, List, Tuple
# ============================================================================
# 애플리케이션 정보
# ============================================================================
APP_NAME = "전동차 업무 인수인계 시스템 (Created by ChoiKH)"
APP_NAME_EN = "Train Handover System"
APP_VERSION = "1.0.0"
APP_AUTHOR = "검수팀"
APP_DESCRIPTION = "전동차 운용실 업무 인수인계 및 고장관리 프로그램"
# ============================================================================
# 경로 상수
# ============================================================================
# 프로젝트 루트 디렉토리
ROOT_DIR = Path(__file__).parent.parent.absolute()
# 데이터 디렉토리
DATA_DIR = ROOT_DIR / "data"
LOGS_DIR = ROOT_DIR / "logs"
ASSETS_DIR = ROOT_DIR / "assets"
FONTS_DIR = ASSETS_DIR / "fonts"
ICONS_DIR = ASSETS_DIR / "icons"
IMAGES_DIR = ASSETS_DIR / "images"
STYLES_DIR = ASSETS_DIR / "styles"
# 데이터베이스 파일
DB_FILE = DATA_DIR / "handover.db"
# 설정 파일
CONFIG_FILE = ROOT_DIR / "config.ini"
# ============================================================================
# 팀 관련 상수
# ============================================================================
# 팀 목록
TEAMS: List[str] = ["1팀", "2팀", "3팀", "4팀"]
# 근무 유형
SHIFT_TYPES: Dict[str, str] = {
"day": "주간",
"night": "야간",
}
# 팀 직책
TEAM_POSITIONS: Dict[str, Dict] = {
"vice_leader": {
"name": "부팀장",
"count": 2, # 각 팀당 2명
},
"operator": {
"name": "운용",
"count": 3, # 각 팀당 2~3명
},
}
# ============================================================================
# 부서 및 권한 관련 상수
# ============================================================================
# 부서 목록
DEPARTMENTS: List[str] = [
"검수팀",
"운전팀",
"차량팀",
"관제팀",
"기타",
]
# 역할 목록
ROLES: Dict[str, str] = {
"admin": "관리자",
"editor": "편집자",
"viewer": "조회자",
}
# 부서별 기본 권한
DEPARTMENT_PERMISSIONS: Dict[str, str] = {
"검수팀": "admin", # 모든 권한
"운전팀": "viewer", # 조회만
"차량팀": "viewer", # 조회만
"관제팀": "viewer", # 조회만
"기타": "viewer", # 조회만
}
# ============================================================================
# 청소 유형 상수
# ============================================================================
CLEANING_TYPES: Dict[str, Dict] = {
"none": {
"name": "없음",
"color": None,
"shape": None,
},
"medium": {
"name": "중청소",
"color": "#3498db", # 파란색
"shape": "rectangle",
},
"large": {
"name": "대청소",
"color": "#e74c3c", # 빨간색
"shape": "circle",
},
}
# ============================================================================
# 고장 코드 관련 상수
# ============================================================================
# JSON 데이터 파일 경로
FAULT_DATA_FILE = DATA_DIR / "fault_data.json"
# JSON에서 데이터 로드
def _load_fault_data():
"""고장 관련 데이터를 JSON 파일에서 로드"""
import json
try:
if FAULT_DATA_FILE.exists():
with open(FAULT_DATA_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
else:
# 기본값 반환
return {
"train_numbers": [],
"stations": [],
"column_numbers": [],
"device_categories": [
"추진장치", "제동장치", "출입문", "냉난방", "조명",
"방송", "ATC/ATO", "집전장치", "차체", "대차", "기타"
],
"fault_codes": []
}
except (json.JSONDecodeError, IOError, OSError):
# 로드 실패 시 기본값 반환
return {
"train_numbers": [],
"stations": [],
"column_numbers": [],
"device_categories": [
"추진장치", "제동장치", "출입문", "냉난방", "조명",
"방송", "ATC/ATO", "집전장치", "차체", "대차", "기타"
],
"fault_codes": []
}
# 고장 데이터 로드
_fault_data = _load_fault_data()
# 편성번호 목록
TRAIN_NUMBERS: List[str] = _fault_data.get("train_numbers", [])
# 역 목록
STATIONS: List[str] = _fault_data.get("stations", [])
# 열번 목록
COLUMN_NUMBERS: List[str] = _fault_data.get("column_numbers", [])
# 장치 분류
DEVICE_CATEGORIES: List[str] = _fault_data.get("device_categories", [
"추진장치", "제동장치", "출입문", "냉난방", "조명",
"방송", "ATC/ATO", "집전장치", "차체", "대차", "기타"
])
# 고장코드 목록
FAULT_CODES: List[str] = _fault_data.get("fault_codes", [])
# 고장출처 목록
FAULT_SOURCES: List[str] = [
"일상검수",
"출고검수",
"도착검수",
"입고기관사",
"상태권",
"직원모니터링",
"기타"
]
# ============================================================================
# UI 관련 상수
# ============================================================================
# 레이아웃 비율 (퍼센트)
LAYOUT_RATIOS: Dict[str, float] = {
"info_bar": 10.0, # 상단 인포바
"content_area": 80.0, # 중앙 컨텐츠
"status_bar": 10.0, # 하단 상태바
"section_panel": 60.0, # 섹션 패널 (컨텐츠 내 좌측)
"todo_panel": 40.0, # Todo 패널 (컨텐츠 내 우측)
"daily_inspection": 30.0, # 일상검수 영역
"todo_list": 35.0, # 할일 목록 영역
"memo": 35.0, # 메모 영역
}
# 폰트 설정
FONT_FAMILY = "GmarketSans"
FONT_SIZES: Dict[str, int] = {
"title": 18,
"heading": 16,
"subheading": 14,
"body": 13,
"small": 11,
"tiny": 9,
}
# UI 영역별 폰트 설정
UI_FONT_SETTINGS: Dict[str, Dict] = {
# 인포바
"info_bar": {
"title": {"family": FONT_FAMILY, "size": 16, "weight": "bold"},
"content": {"family": FONT_FAMILY, "size": 14, "weight": "normal"},
"small": {"family": FONT_FAMILY, "size": 12, "weight": "normal"},
},
# 섹션 (지시, 고장, 작업, 기타)
"section": {
"title": {"family": FONT_FAMILY, "size": 16, "weight": "bold"},
"header": {"family": FONT_FAMILY, "size": 13, "weight": "bold"},
"content": {"family": FONT_FAMILY, "size": 13, "weight": "normal"},
"small": {"family": FONT_FAMILY, "size": 11, "weight": "normal"},
},
# 할일 목록
"todo": {
"title": {"family": FONT_FAMILY, "size": 14, "weight": "bold"},
"content": {"family": FONT_FAMILY, "size": 13, "weight": "normal"},
"small": {"family": FONT_FAMILY, "size": 11, "weight": "normal"},
},
# 메모
"memo": {
"title": {"family": FONT_FAMILY, "size": 14, "weight": "bold"},
"content": {"family": FONT_FAMILY, "size": 13, "weight": "normal"},
},
# 일상검수
"daily_inspection": {
"title": {"family": FONT_FAMILY, "size": 14, "weight": "bold"},
"content": {"family": FONT_FAMILY, "size": 12, "weight": "normal"},
"train_number": {"family": FONT_FAMILY, "size": 13, "weight": "bold"},
},
# 상태바
"status_bar": {
"content": {"family": FONT_FAMILY, "size": 12, "weight": "normal"},
},
# 다이얼로그
"dialog": {
"title": {"family": FONT_FAMILY, "size": 12, "weight": "bold"},
"label": {"family": FONT_FAMILY, "size": 10, "weight": "normal"},
"input": {"family": FONT_FAMILY, "size": 10, "weight": "normal"},
"button": {"family": FONT_FAMILY, "size": 10, "weight": "medium"},
},
}
# 테마 색상 (다크 테마)
DARK_THEME_COLORS: Dict[str, str] = {
"primary": "#3498db",
"secondary": "#2ecc71",
"accent": "#e74c3c",
"warning": "#f39c12",
"background": "#1a1a2e",
"surface": "#16213e",
"card": "#0f3460",
"text_primary": "#ffffff",
"text_secondary": "#a0a0a0",
"border": "#2d3748",
"hover": "#4a5568",
"success": "#48bb78",
"error": "#fc8181",
}
# 테마 색상 (라이트 테마)
LIGHT_THEME_COLORS: Dict[str, str] = {
"primary": "#2980b9",
"secondary": "#27ae60",
"accent": "#c0392b",
"warning": "#d68910",
"background": "#f5f7fa",
"surface": "#ffffff",
"card": "#ffffff",
"text_primary": "#2c3e50",
"text_secondary": "#7f8c8d",
"border": "#e0e0e0",
"hover": "#ecf0f1",
"success": "#2ecc71",
"error": "#e74c3c",
}
# ============================================================================
# 일상검수 관련 상수
# ============================================================================
# 일상검수 슬롯 수
DAILY_INSPECTION_SLOTS = 5
# ============================================================================
# 시간 관련 상수
# ============================================================================
# 업데이트 체크 주기 (초)
UPDATE_CHECK_INTERVAL = 3600 # 1시간
# 날씨 정보 갱신 주기 (초)
WEATHER_UPDATE_INTERVAL = 1800 # 30분
# 자동 저장 주기 (초)
AUTO_SAVE_INTERVAL = 30 # 30초
# 로그 파일 보관 기간 (일)
LOG_RETENTION_DAYS = 30
# ============================================================================
# 날씨 API 관련 상수
# ============================================================================
# 기상청 API (예시)
WEATHER_API_URL = "https://api.openweathermap.org/data/2.5/weather"
WEATHER_API_KEY = "" # 설정 파일에서 로드
# 기본 위치 (서울)
DEFAULT_LOCATION: Dict[str, float] = {
"lat": 37.5665,
"lon": 126.9780,
"name": "서울",
}
# ============================================================================
# 데이터베이스 관련 상수
# ============================================================================
# 테이블 이름
TABLE_NAMES: Dict[str, str] = {
"users": "users",
"teams": "teams",
"instructions": "instructions",
"faults": "faults",
"works": "works",
"miscs": "miscs",
"daily_inspections": "daily_inspections",
"todos": "todos",
"memos": "memos",
"settings": "settings",
}
# 팀 확인 상태 기본값
DEFAULT_TEAM_CONFIRMATIONS = {
"1팀": False,
"2팀": False,
"3팀": False,
"4팀": False,
}

142
core/dia_data_loader.py Normal file
View File

@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""
열차 운행 데이터 로더 모듈
dia_data.json 파일을 로드하고 열번과 역명으로 시간을 추정하는 기능을 제공합니다.
"""
import json
from pathlib import Path
from datetime import time, date
from typing import Optional, Dict, List
from core.logger import get_logger
logger = get_logger(__name__)
# 전역 캐시
_dia_data_cache: Optional[List[Dict]] = None
def load_dia_data() -> List[Dict]:
"""
dia_data.json 파일을 로드합니다.
Returns:
운행 데이터 리스트
"""
global _dia_data_cache
if _dia_data_cache is not None:
return _dia_data_cache
try:
# data 디렉토리에서 파일 찾기
data_dir = Path(__file__).parent.parent / "data"
dia_file = data_dir / "dia_data.json"
if not dia_file.exists():
logger.warning(f"dia_data.json 파일을 찾을 수 없습니다: {dia_file}")
return []
with open(dia_file, 'r', encoding='utf-8') as f:
_dia_data_cache = json.load(f)
logger.info(f"dia_data.json 로드 완료: {len(_dia_data_cache)}개 레코드")
return _dia_data_cache
except Exception as e:
logger.error(f"dia_data.json 로드 실패: {e}")
return []
def estimate_time_by_column_station(
column_number: str,
station: str,
occurrence_date: Optional[date] = None
) -> Optional[time]:
"""
열번과 역명으로 발생 시간을 추정합니다.
Args:
column_number: 열번 (: "2002", "1017")
station: 역명 (: "노포", "신평")
occurrence_date: 발생일 (평일/주말 판단용)
Returns:
추정 시간 또는 None
"""
if not column_number or not station:
return None
try:
# 열번을 정수로 변환
column_num = int(column_number)
except (ValueError, TypeError):
return None
# 요일 구분 판단
if occurrence_date:
weekday = occurrence_date.weekday() # 0=월요일, 6=일요일
if weekday < 5: # 월~금
day_type = "평일"
elif weekday == 5: # 토요일
day_type = "토요일"
else: # 일요일
day_type = "일요일/공휴일"
else:
day_type = "평일" # 기본값은 평일
# 데이터 로드
dia_data = load_dia_data()
# 매칭되는 데이터 찾기
for record in dia_data:
if (record.get("열번") == column_num and
record.get("역명") == station and
record.get("요일구분") == day_type):
time_str = record.get("시간", "")
if time_str:
try:
# "06:22:30" 형식을 time 객체로 변환
parts = time_str.split(":")
if len(parts) >= 2:
hour = int(parts[0])
minute = int(parts[1])
return time(hour, minute)
except (ValueError, IndexError):
continue
return None
def get_stations_by_column(
column_number: str,
day_type: str = "평일"
) -> List[str]:
"""
열번으로 해당 열차가 정차하는 목록을 반환합니다.
Args:
column_number: 열번
day_type: 요일 구분 (평일, 토요일, 일요일/공휴일)
Returns:
역명 리스트
"""
try:
column_num = int(column_number)
except (ValueError, TypeError):
return []
dia_data = load_dia_data()
stations = set()
for record in dia_data:
if (record.get("열번") == column_num and
record.get("요일구분") == day_type):
station = record.get("역명")
if station:
stations.add(station)
return sorted(list(stations))

361
core/exceptions.py Normal file
View File

@ -0,0 +1,361 @@
# -*- coding: utf-8 -*-
"""
커스텀 예외 모듈
애플리케이션에서 사용되는 커스텀 예외 클래스들을 정의합니다.
예외는 특정 오류 상황을 명확하게 나타내며,
적절한 오류 처리를 가능하게 합니다.
"""
class HandoverBaseException(Exception):
"""
애플리케이션 기본 예외 클래스
모든 커스텀 예외의 기반 클래스입니다.
"""
def __init__(self, message: str = "알 수 없는 오류가 발생했습니다."):
self.message = message
super().__init__(self.message)
def __str__(self):
return f"[{self.__class__.__name__}] {self.message}"
# ============================================================================
# 데이터베이스 관련 예외
# ============================================================================
class DatabaseException(HandoverBaseException):
"""데이터베이스 관련 기본 예외"""
pass
class DatabaseConnectionError(DatabaseException):
"""데이터베이스 연결 실패 예외"""
def __init__(self, message: str = "데이터베이스 연결에 실패했습니다."):
super().__init__(message)
class DatabaseQueryError(DatabaseException):
"""데이터베이스 쿼리 실행 실패 예외"""
def __init__(self, message: str = "데이터베이스 쿼리 실행에 실패했습니다.", query: str = None):
self.query = query
if query:
message = f"{message} (Query: {query[:100]}...)"
super().__init__(message)
class RecordNotFoundError(DatabaseException):
"""레코드를 찾을 수 없는 예외"""
def __init__(self, table: str = None, record_id: int = None):
message = "레코드를 찾을 수 없습니다."
if table and record_id:
message = f"테이블 '{table}'에서 ID {record_id}인 레코드를 찾을 수 없습니다."
super().__init__(message)
class DuplicateRecordError(DatabaseException):
"""중복 레코드 예외"""
def __init__(self, message: str = "중복된 레코드가 존재합니다."):
super().__init__(message)
# ============================================================================
# 인증/권한 관련 예외
# ============================================================================
class AuthException(HandoverBaseException):
"""인증 관련 기본 예외"""
pass
class AuthenticationError(AuthException):
"""인증 실패 예외"""
def __init__(self, message: str = "인증에 실패했습니다."):
super().__init__(message)
class InvalidCredentialsError(AuthException):
"""잘못된 자격 증명 예외"""
def __init__(self, message: str = "아이디 또는 비밀번호가 올바르지 않습니다."):
super().__init__(message)
class PermissionDeniedError(AuthException):
"""권한 없음 예외"""
def __init__(self, action: str = None):
message = "이 작업을 수행할 권한이 없습니다."
if action:
message = f"'{action}' 작업을 수행할 권한이 없습니다."
super().__init__(message)
class SessionExpiredError(AuthException):
"""세션 만료 예외"""
def __init__(self, message: str = "세션이 만료되었습니다. 다시 로그인해주세요."):
super().__init__(message)
class UserNotActiveError(AuthException):
"""비활성 사용자 예외"""
def __init__(self, message: str = "비활성화된 계정입니다. 관리자에게 문의하세요."):
super().__init__(message)
# ============================================================================
# 설정 관련 예외
# ============================================================================
class ConfigException(HandoverBaseException):
"""설정 관련 기본 예외"""
pass
class ConfigFileNotFoundError(ConfigException):
"""설정 파일을 찾을 수 없는 예외"""
def __init__(self, filepath: str = None):
message = "설정 파일을 찾을 수 없습니다."
if filepath:
message = f"설정 파일을 찾을 수 없습니다: {filepath}"
super().__init__(message)
class ConfigParseError(ConfigException):
"""설정 파일 파싱 오류 예외"""
def __init__(self, message: str = "설정 파일 파싱에 실패했습니다."):
super().__init__(message)
class InvalidConfigValueError(ConfigException):
"""잘못된 설정 값 예외"""
def __init__(self, key: str = None, value: str = None):
message = "잘못된 설정 값입니다."
if key:
message = f"잘못된 설정 값입니다: {key}={value}"
super().__init__(message)
# ============================================================================
# 유효성 검사 관련 예외
# ============================================================================
class ValidationException(HandoverBaseException):
"""유효성 검사 관련 기본 예외"""
pass
class InvalidInputError(ValidationException):
"""잘못된 입력 예외"""
def __init__(self, field: str = None, message: str = None):
if message:
error_message = message
elif field:
error_message = f"'{field}' 필드의 입력값이 올바르지 않습니다."
else:
error_message = "입력값이 올바르지 않습니다."
super().__init__(error_message)
class RequiredFieldError(ValidationException):
"""필수 필드 누락 예외"""
def __init__(self, field: str = None):
message = "필수 필드가 누락되었습니다."
if field:
message = f"필수 필드 '{field}'이(가) 누락되었습니다."
super().__init__(message)
class InvalidDateFormatError(ValidationException):
"""잘못된 날짜 형식 예외"""
def __init__(self, value: str = None, expected_format: str = "YYYY-MM-DD"):
message = f"잘못된 날짜 형식입니다. 예상 형식: {expected_format}"
if value:
message = f"'{value}'은(는) 올바른 날짜 형식이 아닙니다. 예상 형식: {expected_format}"
super().__init__(message)
class InvalidTimeFormatError(ValidationException):
"""잘못된 시간 형식 예외"""
def __init__(self, value: str = None, expected_format: str = "HH:MM"):
message = f"잘못된 시간 형식입니다. 예상 형식: {expected_format}"
if value:
message = f"'{value}'은(는) 올바른 시간 형식이 아닙니다. 예상 형식: {expected_format}"
super().__init__(message)
# ============================================================================
# 네트워크 관련 예외
# ============================================================================
class NetworkException(HandoverBaseException):
"""네트워크 관련 기본 예외"""
pass
class NetworkConnectionError(NetworkException):
"""네트워크 연결 실패 예외"""
def __init__(self, message: str = "네트워크 연결에 실패했습니다."):
super().__init__(message)
class APIRequestError(NetworkException):
"""API 요청 실패 예외"""
def __init__(self, url: str = None, status_code: int = None):
message = "API 요청에 실패했습니다."
if url and status_code:
message = f"API 요청 실패: {url} (상태 코드: {status_code})"
super().__init__(message)
class SyncError(NetworkException):
"""동기화 실패 예외"""
def __init__(self, message: str = "데이터 동기화에 실패했습니다."):
super().__init__(message)
# ============================================================================
# 업데이트 관련 예외
# ============================================================================
class UpdateException(HandoverBaseException):
"""업데이트 관련 기본 예외"""
pass
class UpdateCheckError(UpdateException):
"""업데이트 확인 실패 예외"""
def __init__(self, message: str = "업데이트 확인에 실패했습니다."):
super().__init__(message)
class UpdateDownloadError(UpdateException):
"""업데이트 다운로드 실패 예외"""
def __init__(self, message: str = "업데이트 다운로드에 실패했습니다."):
super().__init__(message)
class UpdateInstallError(UpdateException):
"""업데이트 설치 실패 예외"""
def __init__(self, message: str = "업데이트 설치에 실패했습니다."):
super().__init__(message)
# ============================================================================
# 파일 관련 예외
# ============================================================================
class FileException(HandoverBaseException):
"""파일 관련 기본 예외"""
pass
class FileNotFoundError(FileException):
"""파일을 찾을 수 없는 예외"""
def __init__(self, filepath: str = None):
message = "파일을 찾을 수 없습니다."
if filepath:
message = f"파일을 찾을 수 없습니다: {filepath}"
super().__init__(message)
class FileReadError(FileException):
"""파일 읽기 실패 예외"""
def __init__(self, filepath: str = None):
message = "파일 읽기에 실패했습니다."
if filepath:
message = f"파일 읽기에 실패했습니다: {filepath}"
super().__init__(message)
class FileWriteError(FileException):
"""파일 쓰기 실패 예외"""
def __init__(self, filepath: str = None):
message = "파일 쓰기에 실패했습니다."
if filepath:
message = f"파일 쓰기에 실패했습니다: {filepath}"
super().__init__(message)
# ============================================================================
# 내보내기
# ============================================================================
__all__ = [
# 기본 예외
'HandoverBaseException',
# 데이터베이스 예외
'DatabaseException',
'DatabaseConnectionError',
'DatabaseQueryError',
'RecordNotFoundError',
'DuplicateRecordError',
# 인증/권한 예외
'AuthException',
'AuthenticationError',
'InvalidCredentialsError',
'PermissionDeniedError',
'SessionExpiredError',
'UserNotActiveError',
# 설정 예외
'ConfigException',
'ConfigFileNotFoundError',
'ConfigParseError',
'InvalidConfigValueError',
# 유효성 검사 예외
'ValidationException',
'InvalidInputError',
'RequiredFieldError',
'InvalidDateFormatError',
'InvalidTimeFormatError',
# 네트워크 예외
'NetworkException',
'NetworkConnectionError',
'APIRequestError',
'SyncError',
# 업데이트 예외
'UpdateException',
'UpdateCheckError',
'UpdateDownloadError',
'UpdateInstallError',
# 파일 예외
'FileException',
'FileNotFoundError',
'FileReadError',
'FileWriteError',
]

365
core/logger.py Normal file
View File

@ -0,0 +1,365 @@
# -*- coding: utf-8 -*-
"""
로깅 시스템 모듈
애플리케이션의 로깅 기능을 설정하고 관리합니다.
모듈은 다음 기능을 제공합니다:
- 파일 콘솔 로깅
- 일별 로그 로테이션
- 로그 레벨 필터링
- 상세한 로그 포맷팅
"""
import os
import sys
import logging
from datetime import datetime
from pathlib import Path
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
from typing import Optional
from .constants import LOGS_DIR, LOG_RETENTION_DAYS, APP_NAME
# ============================================================================
# 로그 포맷 정의
# ============================================================================
# 콘솔 로그 포맷 (간략)
CONSOLE_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
# 파일 로그 포맷 (상세)
FILE_FORMAT = (
"%(asctime)s | %(levelname)-8s | %(name)s | "
"%(filename)s:%(lineno)d | %(funcName)s | %(message)s"
)
# 날짜 포맷
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
# ============================================================================
# 커스텀 로그 필터
# ============================================================================
class LevelFilter(logging.Filter):
"""
특정 레벨 이상의 로그만 통과시키는 필터
Args:
level: 최소 로그 레벨
"""
def __init__(self, level: int):
super().__init__()
self.level = level
def filter(self, record: logging.LogRecord) -> bool:
return record.levelno >= self.level
class ModuleFilter(logging.Filter):
"""
특정 모듈의 로그만 통과시키는 필터
Args:
modules: 허용할 모듈 이름 리스트
"""
def __init__(self, modules: list):
super().__init__()
self.modules = modules
def filter(self, record: logging.LogRecord) -> bool:
return any(record.name.startswith(module) for module in self.modules)
# ============================================================================
# 커스텀 핸들러
# ============================================================================
class ColoredConsoleHandler(logging.StreamHandler):
"""
컬러 콘솔 출력 핸들러
로그 레벨에 따라 다른 색상으로 출력합니다.
Windows 콘솔에서도 ANSI 색상 코드를 지원합니다.
"""
# ANSI 색상 코드
COLORS = {
'DEBUG': '\033[36m', # Cyan
'INFO': '\033[32m', # Green
'WARNING': '\033[33m', # Yellow
'ERROR': '\033[31m', # Red
'CRITICAL': '\033[35m', # Magenta
'RESET': '\033[0m', # Reset
}
def __init__(self, stream=None):
super().__init__(stream)
# Windows에서 ANSI 색상 코드 활성화
if sys.platform == 'win32':
try:
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(
kernel32.GetStdHandle(-11), 7
)
except Exception:
pass
def emit(self, record: logging.LogRecord):
try:
# 색상 코드 추가
color = self.COLORS.get(record.levelname, self.COLORS['RESET'])
reset = self.COLORS['RESET']
# 원본 메시지 백업
original_msg = record.msg
# 색상 적용
record.msg = f"{color}{record.msg}{reset}"
record.levelname = f"{color}{record.levelname}{reset}"
super().emit(record)
# 원본 메시지 복원
record.msg = original_msg
except Exception:
self.handleError(record)
# ============================================================================
# 로거 설정 함수
# ============================================================================
def setup_logger(
name: str = APP_NAME,
level: int = logging.DEBUG,
log_to_file: bool = True,
log_to_console: bool = True,
log_dir: Optional[Path] = None
) -> logging.Logger:
"""
로거를 설정하고 반환합니다.
함수는 애플리케이션 시작 호출되어야 합니다.
로그는 콘솔과 파일에 동시에 기록됩니다.
Args:
name: 로거 이름 (기본값: 이름)
level: 로그 레벨 (기본값: DEBUG)
log_to_file: 파일 로깅 활성화 여부
log_to_console: 콘솔 로깅 활성화 여부
log_dir: 로그 디렉토리 경로 (기본값: LOGS_DIR)
Returns:
설정된 Logger 객체
Examples:
>>> logger = setup_logger()
>>> logger.info("애플리케이션이 시작되었습니다.")
"""
# 로거 생성
logger = logging.getLogger(name)
logger.setLevel(level)
# 기존 핸들러 제거 (중복 방지)
logger.handlers.clear()
# 콘솔 핸들러 설정
if log_to_console:
console_handler = ColoredConsoleHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_formatter = logging.Formatter(CONSOLE_FORMAT, DATE_FORMAT)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# 파일 핸들러 설정
if log_to_file:
# 로그 디렉토리 생성
log_directory = log_dir or LOGS_DIR
log_directory.mkdir(parents=True, exist_ok=True)
# 로그 파일 경로
log_filename = log_directory / f"app_{datetime.now().strftime('%Y%m%d')}.log"
# 일별 로테이션 핸들러
file_handler = TimedRotatingFileHandler(
filename=log_filename,
when='midnight',
interval=1,
backupCount=LOG_RETENTION_DAYS,
encoding='utf-8'
)
file_handler.setLevel(logging.DEBUG)
file_formatter = logging.Formatter(FILE_FORMAT, DATE_FORMAT)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
# 에러 전용 파일 핸들러
error_filename = log_directory / f"error_{datetime.now().strftime('%Y%m%d')}.log"
error_handler = TimedRotatingFileHandler(
filename=error_filename,
when='midnight',
interval=1,
backupCount=LOG_RETENTION_DAYS,
encoding='utf-8'
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(file_formatter)
logger.addHandler(error_handler)
# 로거가 루트 로거로 전파되지 않도록 설정
logger.propagate = False
return logger
def get_logger(name: str = None) -> logging.Logger:
"""
지정된 이름의 로거를 반환합니다.
모듈별로 별도의 로거를 사용할 호출합니다.
Args:
name: 로거 이름 (기본값: 호출 모듈의 __name__)
Returns:
Logger 객체
Examples:
>>> logger = get_logger(__name__)
>>> logger.debug("디버그 메시지")
"""
if name is None:
# 호출자의 모듈 이름 가져오기
import inspect
frame = inspect.currentframe()
if frame and frame.f_back:
name = frame.f_back.f_globals.get('__name__', APP_NAME)
else:
name = APP_NAME
return logging.getLogger(name)
def set_log_level(level: int, logger_name: str = None):
"""
로거의 로그 레벨을 변경합니다.
Args:
level: 새로운 로그 레벨
logger_name: 로거 이름 (기본값: 루트 로거)
Examples:
>>> set_log_level(logging.WARNING)
"""
logger = logging.getLogger(logger_name) if logger_name else logging.getLogger()
logger.setLevel(level)
for handler in logger.handlers:
handler.setLevel(level)
def cleanup_old_logs(log_dir: Optional[Path] = None, days: int = LOG_RETENTION_DAYS):
"""
오래된 로그 파일을 삭제합니다.
Args:
log_dir: 로그 디렉토리 경로
days: 보관 기간 ()
"""
import time
log_directory = log_dir or LOGS_DIR
if not log_directory.exists():
return
now = time.time()
cutoff = now - (days * 86400) # days to seconds
for log_file in log_directory.glob("*.log*"):
if log_file.stat().st_mtime < cutoff:
try:
log_file.unlink()
except Exception as e:
print(f"로그 파일 삭제 실패: {log_file} - {e}")
# ============================================================================
# 로그 유틸리티 함수
# ============================================================================
def log_function_call(logger: logging.Logger):
"""
함수 호출을 로깅하는 데코레이터
Args:
logger: 사용할 로거
Returns:
데코레이터 함수
Examples:
>>> @log_function_call(logger)
... def my_function(x, y):
... return x + y
"""
def decorator(func):
def wrapper(*args, **kwargs):
logger.debug(f"호출: {func.__name__}(args={args}, kwargs={kwargs})")
try:
result = func(*args, **kwargs)
logger.debug(f"완료: {func.__name__} -> {result}")
return result
except Exception as e:
logger.error(f"예외 발생: {func.__name__} -> {e}")
raise
return wrapper
return decorator
def log_exception(logger: logging.Logger, exc: Exception, extra_info: str = None):
"""
예외를 상세하게 로깅합니다.
Args:
logger: 사용할 로거
exc: 예외 객체
extra_info: 추가 정보
"""
import traceback
error_message = f"예외 발생: {type(exc).__name__}: {exc}"
if extra_info:
error_message = f"{extra_info} - {error_message}"
logger.error(error_message)
logger.debug(f"스택 트레이스:\n{traceback.format_exc()}")
# ============================================================================
# 모듈 레벨 기본 로거
# ============================================================================
# 기본 로거 (모듈 로드 시 설정되지 않음)
_default_logger: Optional[logging.Logger] = None
def get_default_logger() -> logging.Logger:
"""
기본 로거를 반환합니다.
로거가 설정되지 않은 경우 기본 설정으로 초기화합니다.
"""
global _default_logger
if _default_logger is None:
_default_logger = setup_logger()
return _default_logger

892
core/settings_manager.py Normal file
View File

@ -0,0 +1,892 @@
# -*- coding: utf-8 -*-
"""
설정 관리자 모듈 (Settings Manager)
사용자 설정을 별도의 SQLite 데이터베이스로 관리합니다.
모듈은 다음 기능을 제공합니다:
- 사용자 설정 저장/로드 (SQLite DB)
- 팀별 필드 설정 관리
- 마스터 데이터 관리 (편성, 역명, 제조사 )
- Supabase 동기화 준비
향후 Supabase 통합 :
- 로컬 DB는 오프라인 캐시로 사용
- 온라인 Supabase와 동기화
- 충돌 해결 전략 적용
"""
import sqlite3
import json
import threading
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple
from contextlib import contextmanager
from dataclasses import dataclass, asdict
from datetime import datetime
from .constants import DATA_DIR, TEAMS
from .logger import get_logger
logger = get_logger(__name__)
# ============================================================================
# 데이터 클래스
# ============================================================================
@dataclass
class FieldSetting:
"""필드 설정 데이터 클래스"""
name: str
visible: bool = True
width: int = 100
display_format: Optional[str] = None # "full", "short", "month_day" 등
@dataclass
class TrainInfo:
"""편성 정보 데이터 클래스 (마스터 데이터)"""
train_number: str # 편성번호 (예: "101A")
train_type: str = "B" # A: 구형, B: 신형
manufacturer: str = "" # 제조사
manufacture_year: int = 0 # 제조년도
depot: str = "" # 소속 차량사업소
is_active: bool = True
@dataclass
class StationInfo:
"""역 정보 데이터 클래스 (마스터 데이터)"""
station_code: str # 역 코드
station_name: str # 역명
line_number: int = 1 # 호선
order: int = 0 # 순서
is_active: bool = True
@dataclass
class ManufacturerInfo:
"""제조사 정보 데이터 클래스"""
id: int
name: str # 제조사명
@dataclass
class FaultCodeInfo:
"""고장 코드 정보 데이터 클래스"""
f_code: str # 고장 코드
f_code_num: Optional[int] = None # 고장 코드 번호
f_name: Optional[str] = None # 고장명
car_type: Optional[str] = None # 차량 타입
f_class: Optional[str] = None # 고장 분류
fault_name: Optional[str] = None # 고장 상세명
grade: Optional[str] = None # 등급
device: Optional[str] = None # 장치
fault_detail: Optional[str] = None # 고장 상세
fault_action: Optional[str] = None # 조치 방법
manufacturer: Optional[str] = None # 제조사
# ============================================================================
# SQL 스키마
# ============================================================================
SETTINGS_DB_SCHEMA = """
-- 사용자 설정 테이블 (- 형태)
CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team TEXT NOT NULL, -- 운용당무 (1, 2, 3, 4)
category TEXT NOT NULL, -- 설정 카테고리 (field_settings, ui_settings, etc.)
key TEXT NOT NULL, -- 설정
value TEXT, -- JSON 형태의
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(team, category, key)
);
-- 필드 설정 테이블 (섹션별 필드 설정)
CREATE TABLE IF NOT EXISTS field_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team TEXT NOT NULL, -- 운용당무
section_name TEXT NOT NULL, -- 섹션 이름 (지시, 고장, 작업, 기타)
field_name TEXT NOT NULL, -- 필드 이름
visible INTEGER DEFAULT 1, -- 표시 여부
width INTEGER DEFAULT 100, -- 너비
display_format TEXT, -- 표시 형식
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(team, section_name, field_name)
);
-- 편성 정보 테이블 (마스터 데이터)
CREATE TABLE IF NOT EXISTS train_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
train_number TEXT UNIQUE NOT NULL, -- 편성번호
train_type TEXT DEFAULT 'B', -- A: 구형, B: 신형
manufacturer TEXT, -- 제조사
manufacture_year INTEGER, -- 제조년도
depot TEXT, -- 소속 차량사업소
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
synced_at DATETIME -- Supabase 동기화 시간
);
-- 정보 테이블 (마스터 데이터)
CREATE TABLE IF NOT EXISTS station_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
station_code TEXT UNIQUE NOT NULL, -- 코드
station_name TEXT NOT NULL, -- 역명
line_number INTEGER DEFAULT 1, -- 호선
"order" INTEGER DEFAULT 0, -- 순서
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
synced_at DATETIME -- Supabase 동기화 시간
);
-- 제조사 정보 테이블 (마스터 데이터)
CREATE TABLE IF NOT EXISTS manufacturer_info (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL, -- 제조사명
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
synced_at DATETIME -- Supabase 동기화 시간
);
-- 고장 코드 테이블 (마스터 데이터)
CREATE TABLE IF NOT EXISTS fault_code_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
f_code TEXT NOT NULL, -- 고장 코드
f_code_num INTEGER, -- 고장 코드 번호
f_name TEXT, -- 고장명
car_type TEXT, -- 차량 타입
f_class TEXT, -- 고장 분류
fault_name TEXT, -- 고장 상세명
grade TEXT, -- 등급
device TEXT, -- 장치
fault_detail TEXT, -- 고장 상세
fault_action TEXT, -- 조치 방법
manufacturer TEXT, -- 제조사
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
synced_at DATETIME, -- Supabase 동기화 시간
UNIQUE(f_code, car_type, manufacturer)
);
-- 동기화 상태 테이블
CREATE TABLE IF NOT EXISTS sync_status (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT UNIQUE NOT NULL, -- 테이블 이름
last_synced_at DATETIME, -- 마지막 동기화 시간
last_sync_hash TEXT, -- 마지막 동기화 해시 (변경 감지용)
status TEXT DEFAULT 'pending' -- pending, syncing, synced, error
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_user_settings_team ON user_settings(team);
CREATE INDEX IF NOT EXISTS idx_field_settings_team_section ON field_settings(team, section_name);
CREATE INDEX IF NOT EXISTS idx_train_info_number ON train_info(train_number);
CREATE INDEX IF NOT EXISTS idx_station_info_name ON station_info(station_name);
CREATE INDEX IF NOT EXISTS idx_manufacturer_name ON manufacturer_info(name);
CREATE INDEX IF NOT EXISTS idx_fault_code ON fault_code_info(f_code);
CREATE INDEX IF NOT EXISTS idx_fault_code_car_type ON fault_code_info(car_type);
"""
# ============================================================================
# 설정 관리자 클래스
# ============================================================================
class SettingsManager:
"""
설정 관리자 클래스
사용자 설정을 별도의 SQLite 데이터베이스로 관리합니다.
Supabase 통합을 고려한 구조로 설계되었습니다.
Examples:
>>> settings = SettingsManager()
>>> settings.save_field_settings("1팀", "고장", [FieldSetting("train_number", True, 80)])
>>> fields = settings.load_field_settings("1팀", "고장")
"""
_instance: Optional['SettingsManager'] = None
_lock = threading.Lock()
def __new__(cls):
"""싱글톤 패턴"""
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
"""초기화"""
if self._initialized:
return
self.db_path = DATA_DIR / "settings.db"
self._local = threading.local()
# 데이터 디렉토리 생성
DATA_DIR.mkdir(parents=True, exist_ok=True)
# 데이터베이스 초기화
self._init_database()
self._initialized = True
logger.info(f"설정 관리자 초기화 완료: {self.db_path}")
def _init_database(self):
"""데이터베이스 초기화"""
try:
with self.get_connection() as conn:
conn.executescript(SETTINGS_DB_SCHEMA)
conn.commit()
logger.info("설정 데이터베이스 스키마 생성 완료")
except Exception as e:
logger.error(f"설정 데이터베이스 초기화 실패: {e}")
raise
@contextmanager
def get_connection(self):
"""스레드별 연결 관리"""
if not hasattr(self._local, 'conn') or self._local.conn is None:
self._local.conn = sqlite3.connect(
self.db_path,
check_same_thread=False,
detect_types=sqlite3.PARSE_DECLTYPES
)
self._local.conn.row_factory = sqlite3.Row
try:
yield self._local.conn
except Exception as e:
self._local.conn.rollback()
raise e
def close(self):
"""연결 종료"""
if hasattr(self._local, 'conn') and self._local.conn:
self._local.conn.close()
self._local.conn = None
# ========================================================================
# 필드 설정 관리
# ========================================================================
def save_field_settings(
self,
team: str,
section_name: str,
fields: List[FieldSetting]
):
"""
필드 설정 저장
Args:
team: 이름 (: "1팀")
section_name: 섹션 이름 (: "고장")
fields: 필드 설정 리스트
"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
for field in fields:
cursor.execute("""
INSERT OR REPLACE INTO field_settings
(team, section_name, field_name, visible, width, display_format, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
team,
section_name,
field.name,
1 if field.visible else 0,
field.width,
field.display_format,
datetime.now().isoformat()
))
conn.commit()
logger.info(f"필드 설정 저장 완료: {team} - {section_name} ({len(fields)}개 필드)")
except Exception as e:
logger.error(f"필드 설정 저장 실패: {e}")
raise
def load_field_settings(
self,
team: str,
section_name: str
) -> Optional[List[FieldSetting]]:
"""
필드 설정 로드
Args:
team: 이름
section_name: 섹션 이름
Returns:
필드 설정 리스트 (없으면 None)
"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT field_name, visible, width, display_format
FROM field_settings
WHERE team = ? AND section_name = ?
""", (team, section_name))
rows = cursor.fetchall()
if not rows:
return None
fields = []
for row in rows:
field = FieldSetting(
name=row['field_name'],
visible=bool(row['visible']),
width=row['width'] or 100,
display_format=row['display_format']
)
fields.append(field)
return fields
except Exception as e:
logger.error(f"필드 설정 로드 실패: {e}")
return None
def apply_field_settings_to_fields(
self,
team: str,
section_name: str,
fields: List[Any] # FieldConfig 리스트
):
"""
저장된 설정을 필드에 적용
Args:
team: 이름
section_name: 섹션 이름
fields: FieldConfig 리스트
"""
saved_fields = self.load_field_settings(team, section_name)
if not saved_fields:
return
# 필드 이름으로 매핑
saved_dict = {f.name: f for f in saved_fields}
# 각 필드에 설정 적용
for field in fields:
if field.name in saved_dict:
saved = saved_dict[field.name]
field.visible = saved.visible
field.width = saved.width
if saved.display_format is not None:
field.display_format = saved.display_format
logger.debug(f"필드 설정 적용 완료: {team} - {section_name}")
def reset_team_field_settings(self, team: str):
"""팀별 필드 설정 초기화"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM field_settings WHERE team = ?", (team,))
conn.commit()
logger.info(f"팀 필드 설정 초기화: {team}")
except Exception as e:
logger.error(f"팀 필드 설정 초기화 실패: {e}")
def reset_all_field_settings(self):
"""모든 필드 설정 초기화"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM field_settings")
conn.commit()
logger.info("모든 필드 설정 초기화 완료")
except Exception as e:
logger.error(f"모든 필드 설정 초기화 실패: {e}")
# ========================================================================
# 사용자 설정 관리 (일반 키-값)
# ========================================================================
def save_setting(
self,
team: str,
category: str,
key: str,
value: Any
):
"""
일반 설정 저장
Args:
team: 이름
category: 카테고리 (: "ui_settings", "preferences")
key: 설정
value: 설정 (JSON 직렬화 가능해야 )
"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
# 값을 JSON 문자열로 변환
json_value = json.dumps(value, ensure_ascii=False)
cursor.execute("""
INSERT OR REPLACE INTO user_settings
(team, category, key, value, updated_at)
VALUES (?, ?, ?, ?, ?)
""", (team, category, key, json_value, datetime.now().isoformat()))
conn.commit()
logger.debug(f"설정 저장: {team}/{category}/{key}")
except Exception as e:
logger.error(f"설정 저장 실패: {e}")
raise
def load_setting(
self,
team: str,
category: str,
key: str,
default: Any = None
) -> Any:
"""
일반 설정 로드
Args:
team: 이름
category: 카테고리
key: 설정
default: 기본값
Returns:
설정
"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT value FROM user_settings
WHERE team = ? AND category = ? AND key = ?
""", (team, category, key))
row = cursor.fetchone()
if row:
return json.loads(row['value'])
return default
except Exception as e:
logger.error(f"설정 로드 실패: {e}")
return default
def load_settings_by_category(
self,
team: str,
category: str
) -> Dict[str, Any]:
"""
카테고리별 모든 설정 로드
Args:
team: 이름
category: 카테고리
Returns:
설정 딕셔너리 {key: value}
"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT key, value FROM user_settings
WHERE team = ? AND category = ?
""", (team, category))
result = {}
for row in cursor.fetchall():
try:
result[row['key']] = json.loads(row['value'])
except json.JSONDecodeError:
result[row['key']] = row['value']
return result
except Exception as e:
logger.error(f"카테고리 설정 로드 실패: {e}")
return {}
# ========================================================================
# 마스터 데이터 관리
# ========================================================================
def save_train_info(self, train: TrainInfo):
"""편성 정보 저장"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO train_info
(train_number, train_type, manufacturer, manufacture_year, depot, is_active, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
train.train_number,
train.train_type,
train.manufacturer,
train.manufacture_year,
train.depot,
1 if train.is_active else 0,
datetime.now().isoformat()
))
conn.commit()
except Exception as e:
logger.error(f"편성 정보 저장 실패: {e}")
raise
def load_train_info(self, train_number: str) -> Optional[TrainInfo]:
"""편성 정보 로드"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM train_info WHERE train_number = ?
""", (train_number,))
row = cursor.fetchone()
if row:
return TrainInfo(
train_number=row['train_number'],
train_type=row['train_type'],
manufacturer=row['manufacturer'] or "",
manufacture_year=row['manufacture_year'] or 0,
depot=row['depot'] or "",
is_active=bool(row['is_active'])
)
return None
except Exception as e:
logger.error(f"편성 정보 로드 실패: {e}")
return None
def get_all_trains(self, active_only: bool = True) -> List[TrainInfo]:
"""모든 편성 정보 조회"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
if active_only:
cursor.execute("SELECT * FROM train_info WHERE is_active = 1 ORDER BY train_number")
else:
cursor.execute("SELECT * FROM train_info ORDER BY train_number")
trains = []
for row in cursor.fetchall():
train = TrainInfo(
train_number=row['train_number'],
train_type=row['train_type'],
manufacturer=row['manufacturer'] or "",
manufacture_year=row['manufacture_year'] or 0,
depot=row['depot'] or "",
is_active=bool(row['is_active'])
)
trains.append(train)
return trains
except Exception as e:
logger.error(f"편성 정보 조회 실패: {e}")
return []
def save_station_info(self, station: StationInfo):
"""역 정보 저장"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO station_info
(station_code, station_name, line_number, "order", is_active, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (
station.station_code,
station.station_name,
station.line_number,
station.order,
1 if station.is_active else 0,
datetime.now().isoformat()
))
conn.commit()
except Exception as e:
logger.error(f"역 정보 저장 실패: {e}")
raise
def get_all_stations(self, line_number: int = None, active_only: bool = True) -> List[StationInfo]:
"""모든 역 정보 조회"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
query = "SELECT * FROM station_info"
params = []
conditions = []
if active_only:
conditions.append("is_active = 1")
if line_number:
conditions.append("line_number = ?")
params.append(line_number)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += ' ORDER BY "order"'
cursor.execute(query, params)
stations = []
for row in cursor.fetchall():
station = StationInfo(
station_code=row['station_code'],
station_name=row['station_name'],
line_number=row['line_number'],
order=row['order'],
is_active=bool(row['is_active'])
)
stations.append(station)
return stations
except Exception as e:
logger.error(f"역 정보 조회 실패: {e}")
return []
# ========================================================================
# 제조사 정보 관리
# ========================================================================
def save_manufacturer_info(self, manufacturer: 'ManufacturerInfo'):
"""제조사 정보 저장"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO manufacturer_info
(id, name, updated_at, synced_at)
VALUES (?, ?, ?, ?)
""", (
manufacturer.id,
manufacturer.name,
datetime.now().isoformat(),
datetime.now().isoformat()
))
conn.commit()
except Exception as e:
logger.error(f"제조사 정보 저장 실패: {e}")
raise
def get_all_manufacturers(self) -> List['ManufacturerInfo']:
"""모든 제조사 정보 조회"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM manufacturer_info ORDER BY name")
manufacturers = []
for row in cursor.fetchall():
mfr = ManufacturerInfo(
id=row['id'],
name=row['name']
)
manufacturers.append(mfr)
return manufacturers
except Exception as e:
logger.error(f"제조사 정보 조회 실패: {e}")
return []
# ========================================================================
# 고장 코드 정보 관리
# ========================================================================
def save_fault_code_info(self, fault_code: 'FaultCodeInfo'):
"""고장 코드 정보 저장"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO fault_code_info
(f_code, f_code_num, f_name, car_type, f_class, fault_name,
grade, device, fault_detail, fault_action, manufacturer,
updated_at, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
fault_code.f_code,
fault_code.f_code_num,
fault_code.f_name,
fault_code.car_type,
fault_code.f_class,
fault_code.fault_name,
fault_code.grade,
fault_code.device,
fault_code.fault_detail,
fault_code.fault_action,
fault_code.manufacturer,
datetime.now().isoformat(),
datetime.now().isoformat()
))
conn.commit()
except Exception as e:
logger.error(f"고장 코드 정보 저장 실패: {e}")
raise
def get_fault_codes(
self,
car_type: str = None,
manufacturer: str = None
) -> List['FaultCodeInfo']:
"""고장 코드 정보 조회"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
query = "SELECT * FROM fault_code_info"
params = []
conditions = []
if car_type:
conditions.append("car_type = ?")
params.append(car_type)
if manufacturer:
conditions.append("manufacturer = ?")
params.append(manufacturer)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY f_code_num"
cursor.execute(query, params)
codes = []
for row in cursor.fetchall():
code = FaultCodeInfo(
f_code=row['f_code'],
f_code_num=row['f_code_num'],
f_name=row['f_name'],
car_type=row['car_type'],
f_class=row['f_class'],
fault_name=row['fault_name'],
grade=row['grade'],
device=row['device'],
fault_detail=row['fault_detail'],
fault_action=row['fault_action'],
manufacturer=row['manufacturer']
)
codes.append(code)
return codes
except Exception as e:
logger.error(f"고장 코드 정보 조회 실패: {e}")
return []
def search_fault_codes(self, keyword: str) -> List['FaultCodeInfo']:
"""고장 코드 검색"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
search_term = f"%{keyword}%"
cursor.execute("""
SELECT * FROM fault_code_info
WHERE f_code LIKE ? OR f_name LIKE ? OR fault_name LIKE ? OR device LIKE ?
ORDER BY f_code_num
""", (search_term, search_term, search_term, search_term))
codes = []
for row in cursor.fetchall():
code = FaultCodeInfo(
f_code=row['f_code'],
f_code_num=row['f_code_num'],
f_name=row['f_name'],
car_type=row['car_type'],
f_class=row['f_class'],
fault_name=row['fault_name'],
grade=row['grade'],
device=row['device'],
fault_detail=row['fault_detail'],
fault_action=row['fault_action'],
manufacturer=row['manufacturer']
)
codes.append(code)
return codes
except Exception as e:
logger.error(f"고장 코드 검색 실패: {e}")
return []
# ========================================================================
# Supabase 동기화 준비
# ========================================================================
def get_sync_status(self, table_name: str) -> Dict[str, Any]:
"""동기화 상태 조회"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM sync_status WHERE table_name = ?
""", (table_name,))
row = cursor.fetchone()
if row:
return {
'table_name': row['table_name'],
'last_synced_at': row['last_synced_at'],
'last_sync_hash': row['last_sync_hash'],
'status': row['status']
}
return {'table_name': table_name, 'status': 'never_synced'}
except Exception as e:
logger.error(f"동기화 상태 조회 실패: {e}")
return {'table_name': table_name, 'status': 'error'}
def update_sync_status(
self,
table_name: str,
status: str = 'synced',
sync_hash: str = None
):
"""동기화 상태 업데이트"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO sync_status
(table_name, last_synced_at, last_sync_hash, status)
VALUES (?, ?, ?, ?)
""", (
table_name,
datetime.now().isoformat(),
sync_hash,
status
))
conn.commit()
except Exception as e:
logger.error(f"동기화 상태 업데이트 실패: {e}")
# ============================================================================
# 모듈 레벨 편의 함수
# ============================================================================
def get_settings_manager() -> SettingsManager:
"""설정 관리자 인스턴스 반환"""
return SettingsManager()

209
core/signals.py Normal file
View File

@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
"""
전역 시그널 모듈
애플리케이션 전역에서 사용되는 시그널들을 정의합니다.
Qt 시그널/슬롯 메커니즘을 사용하여 모듈 느슨한 결합을 유지합니다.
모듈을 통해 서로 다른 모듈 간에 이벤트를 전파할 있습니다.
"""
from PySide6.QtCore import QObject, Signal
from typing import Optional
class GlobalSignals(QObject):
"""
전역 시그널 클래스
싱글톤 패턴을 사용하여 애플리케이션 전역에서 하나의 인스턴스만 사용합니다.
다양한 모듈 통신에 사용되는 시그널들을 정의합니다.
Attributes:
data_changed: 데이터 변경 시그널
user_logged_in: 사용자 로그인 시그널
theme_changed: 테마 변경 시그널
team_changed: 변경 시그널
...
Examples:
>>> signals = GlobalSignals()
>>> signals.data_changed.emit("instructions")
>>> signals.user_logged_in.connect(self.on_user_login)
"""
_instance: Optional['GlobalSignals'] = None
# ========================================================================
# 데이터 관련 시그널
# ========================================================================
# 데이터 변경 시그널 (테이블명: str)
data_changed = Signal(str)
# 레코드 생성 시그널 (테이블명: str, 레코드 ID: int)
record_created = Signal(str, int)
# 레코드 업데이트 시그널 (테이블명: str, 레코드 ID: int)
record_updated = Signal(str, int)
# 레코드 삭제 시그널 (테이블명: str, 레코드 ID: int)
record_deleted = Signal(str, int)
# 데이터 동기화 완료 시그널
sync_completed = Signal()
# 데이터 동기화 오류 시그널 (오류 메시지: str)
sync_error = Signal(str)
# ========================================================================
# 사용자/인증 관련 시그널
# ========================================================================
# 사용자 로그인 시그널 (사용자 ID: int, 사용자명: str)
user_logged_in = Signal(int, str)
# 사용자 로그아웃 시그널
user_logged_out = Signal()
# 권한 변경 시그널 (새 권한: str)
permission_changed = Signal(str)
# ========================================================================
# 팀/근무 관련 시그널
# ========================================================================
# 팀 변경 시그널 (새 팀명: str)
team_changed = Signal(str)
# 근무 유형 변경 시그널 (새 근무 유형: str - "주간" 또는 "야간")
shift_changed = Signal(str)
# ========================================================================
# UI 관련 시그널
# ========================================================================
# 테마 변경 시그널 (테마명: str)
theme_changed = Signal(str)
# 레이아웃 변경 시그널
layout_changed = Signal()
# 섹션 탭 변경 시그널 (섹션명: str)
section_tab_changed = Signal(str)
# 상태바 메시지 시그널 (메시지: str, 타임아웃: int)
status_message = Signal(str, int)
# 알림 시그널 (제목: str, 메시지: str, 유형: str)
notification = Signal(str, str, str)
# ========================================================================
# 날씨 관련 시그널
# ========================================================================
# 날씨 정보 업데이트 시그널 (날씨 데이터: dict를 JSON 문자열로)
weather_updated = Signal(str)
# 날씨 업데이트 오류 시그널 (오류 메시지: str)
weather_error = Signal(str)
# 날씨 지역 변경 시그널
weather_location_changed = Signal()
# 날씨 새로고침 요청 시그널
weather_refresh_requested = Signal()
# ========================================================================
# 업데이트 관련 시그널
# ========================================================================
# 업데이트 가능 시그널 (새 버전: str)
update_available = Signal(str)
# 업데이트 진행 시그널 (진행률: int)
update_progress = Signal(int)
# 업데이트 완료 시그널
update_completed = Signal()
# 업데이트 오류 시그널 (오류 메시지: str)
update_error = Signal(str)
# ========================================================================
# 편성 관련 시그널
# ========================================================================
# 편성 선택 시그널 (편성번호: str)
train_selected = Signal(str)
# 편성 정보 팝업 요청 시그널 (편성번호: str, x 위치: int, y 위치: int)
show_train_popup = Signal(str, int, int)
# 편성 정보 팝업 숨김 시그널
hide_train_popup = Signal()
# ========================================================================
# 일상검수 관련 시그널
# ========================================================================
# 일상검수 편성 변경 시그널 (근무 유형: str, 슬롯 번호: int, 편성번호: str)
daily_inspection_changed = Signal(str, int, str)
# ========================================================================
# Todo/메모 관련 시그널
# ========================================================================
# Todo 추가 시그널 (Todo ID: int)
todo_added = Signal(int)
# Todo 완료 상태 변경 시그널 (Todo ID: int, 완료 여부: bool)
todo_status_changed = Signal(int, bool)
# 메모 변경 시그널 (메모 ID: int)
memo_changed = Signal(int)
# ========================================================================
# 시스템 시그널
# ========================================================================
# 애플리케이션 종료 요청 시그널
app_quit_requested = Signal()
# 에러 발생 시그널 (에러 타입: str, 에러 메시지: str)
error_occurred = Signal(str, str)
# 디버그 메시지 시그널 (메시지: str)
debug_message = Signal(str)
def __new__(cls):
"""싱글톤 패턴 구현"""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""초기화"""
# QObject 초기화는 한 번만 수행
if not hasattr(self, '_initialized'):
super().__init__()
self._initialized = True
# ============================================================================
# 모듈 레벨 편의 함수
# ============================================================================
def get_signals() -> GlobalSignals:
"""
전역 시그널 인스턴스를 반환합니다.
Returns:
GlobalSignals 인스턴스
Examples:
>>> signals = get_signals()
>>> signals.data_changed.emit("instructions")
"""
return GlobalSignals()

634
core/supabase_client.py Normal file
View File

@ -0,0 +1,634 @@
# -*- coding: utf-8 -*-
"""
Supabase 클라이언트 모듈
Supabase REST API를 통해 마스터 데이터를 동기화합니다.
지원 테이블:
- Trains: 편성 정보
- Stations: 정보
- Manufacturer: 제조사 정보
- Car_Identity: 차량 식별 정보
- Fault_Code_Table: 고장 코드 테이블
"""
import requests
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from datetime import datetime
import json
from .logger import get_logger
from .settings_manager import (
get_settings_manager, TrainInfo, StationInfo,
ManufacturerInfo, FaultCodeInfo
)
logger = get_logger(__name__)
# ============================================================================
# Supabase 설정
# ============================================================================
SUPABASE_URL = "http://122.35.47.72:8000" # Kong API Gateway (기본 포트)
# 대체 포트: 3000 (PostgREST 직접), 54321 (Supabase Studio 기본)
SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzU4NTUxNjY2LCJleHAiOjQxMDI0NDQ4MDB9.jMCGL3Q-N2o_l7JQE_HrO7Uoct86CMgLsVxpabisG4I"
# ============================================================================
# 데이터 클래스 (Supabase 테이블 매핑)
# ============================================================================
@dataclass
class SupabaseTrainInfo:
"""Supabase Trains 테이블 매핑"""
id: int
train_set: Optional[int] = None # 편성번호 (예: 1, 2, 3...)
train_id: Optional[str] = None # 편성 ID (예: "101")
car_num: Optional[int] = None # 호차 수
car_type: Optional[str] = None # 차량 타입
manufacturer: Optional[str] = None # 제조사
is_new: Optional[bool] = None # 신형 여부
date_of_Commercial_Service: Optional[str] = None # 영업 개시일
date_of_Introduction: Optional[str] = None # 도입일
introduction_stage: Optional[str] = None # 도입 단계
service_nickname: Optional[str] = None # 서비스 별명
@dataclass
class SupabaseStationInfo:
"""Supabase Stations 테이블 매핑"""
id: str
line_number: Optional[int] = None # 호선
station_id: Optional[float] = None # 역 ID
station_name: Optional[str] = None # 역명
station_map: Optional[str] = None # 역 지도
is_underground: Optional[bool] = None # 지하 여부
is_island: Optional[bool] = None # 섬식 승강장
is_exchange: Optional[bool] = None # 환승역
is_end: Optional[bool] = None # 종착역
has_siding_track: Optional[bool] = None # 측선 여부
has_signal_room: Optional[bool] = None # 신호실 여부
@dataclass
class ManufacturerInfo:
"""Supabase Manufacturer 테이블 매핑"""
id: int
manufact: Optional[str] = None # 제조사명
@dataclass
class FaultCodeInfo:
"""Supabase Fault_Code_Table 테이블 매핑"""
id: str
f_code: Optional[str] = None # 고장 코드
f_code_num: Optional[int] = None # 고장 코드 번호
f_name: Optional[str] = None # 고장명
car_type: Optional[str] = None # 차량 타입
f_class: Optional[str] = None # 고장 분류
fault_name: Optional[str] = None # 고장 상세명
grade: Optional[str] = None # 등급
device: Optional[str] = None # 장치
fault_detail: Optional[str] = None # 고장 상세
fault_reaction: Optional[str] = None # 고장 반응
fault_action: Optional[str] = None # 조치 방법
alias_name: Optional[str] = None # 별칭
manufacturer: Optional[str] = None # 제조사
# ============================================================================
# Supabase 클라이언트
# ============================================================================
class SupabaseClient:
"""
Supabase REST API 클라이언트
Docker로 운영 중인 Supabase에서 마스터 데이터를 조회합니다.
Examples:
>>> client = SupabaseClient()
>>> trains = client.get_trains()
>>> stations = client.get_stations()
"""
_instance: Optional['SupabaseClient'] = None
def __new__(cls):
"""싱글톤 패턴"""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
"""초기화"""
if self._initialized:
return
self.base_url = SUPABASE_URL
self.anon_key = SUPABASE_ANON_KEY
self.headers = {
"apikey": self.anon_key,
"Authorization": f"Bearer {self.anon_key}",
"Content-Type": "application/json",
"Prefer": "return=representation"
}
self._initialized = True
logger.info(f"Supabase 클라이언트 초기화: {self.base_url}")
def _request(
self,
method: str,
endpoint: str,
params: Dict[str, Any] = None,
data: Dict[str, Any] = None,
timeout: int = 30
) -> Optional[List[Dict[str, Any]]]:
"""
Supabase REST API 요청
Args:
method: HTTP 메서드 (GET, POST, PATCH, DELETE)
endpoint: API 엔드포인트 (테이블명)
params: 쿼리 파라미터
data: 요청 바디
timeout: 타임아웃 ()
Returns:
응답 데이터 리스트 (실패 None)
"""
url = f"{self.base_url}/rest/v1/{endpoint}"
try:
logger.debug(f"Supabase 요청: {method} {url}")
logger.debug(f" Headers: apikey=***{self.anon_key[-10:]}")
logger.debug(f" Params: {params}")
response = requests.request(
method=method,
url=url,
headers=self.headers,
params=params,
json=data,
timeout=timeout
)
logger.debug(f" 응답 상태: {response.status_code}")
if response.status_code == 200:
result = response.json()
logger.debug(f" 응답 데이터 수: {len(result) if isinstance(result, list) else 'N/A'}")
return result
else:
logger.error(f"Supabase 요청 실패: {response.status_code} - {response.text[:200]}")
return None
except requests.exceptions.Timeout:
logger.error(f"Supabase 요청 타임아웃: {endpoint}")
return None
except requests.exceptions.ConnectionError as e:
logger.error(f"Supabase 연결 실패: {endpoint} - {e}")
return None
except Exception as e:
logger.error(f"Supabase 요청 오류: {e}")
return None
# ========================================================================
# 편성 정보 (Trains)
# ========================================================================
def get_trains(self) -> List[SupabaseTrainInfo]:
"""
Supabase에서 편성 정보 조회
Returns:
편성 정보 리스트
"""
data = self._request("GET", "Trains", params={"order": "train_set.asc"})
if not data:
return []
trains = []
for row in data:
train = SupabaseTrainInfo(
id=row.get("id"),
train_set=row.get("train_set"),
train_id=row.get("train_id"),
car_num=row.get("car_num"),
car_type=row.get("car_type"),
manufacturer=row.get("manufacturer"),
is_new=row.get("is_new"),
date_of_Commercial_Service=row.get("date_of_Commercial_Service"),
date_of_Introduction=row.get("date_of_Introduction"),
introduction_stage=row.get("introduction_stage"),
service_nickname=row.get("service_nickname")
)
trains.append(train)
logger.info(f"Supabase에서 {len(trains)}개 편성 정보 조회 완료")
return trains
# ========================================================================
# 역 정보 (Stations)
# ========================================================================
def get_stations(self, line_number: int = None) -> List[SupabaseStationInfo]:
"""
Supabase에서 정보 조회
Args:
line_number: 호선 (None이면 전체)
Returns:
정보 리스트
"""
params = {"order": "station_id.asc"}
if line_number:
params["line_number"] = f"eq.{line_number}"
data = self._request("GET", "Stations", params=params)
if not data:
return []
stations = []
for row in data:
station = SupabaseStationInfo(
id=row.get("id"),
line_number=row.get("line_number"),
station_id=row.get("station_id"),
station_name=row.get("station_name"),
station_map=row.get("station_map"),
is_underground=row.get("is_underground"),
is_island=row.get("is_island"),
is_exchange=row.get("is_exchange"),
is_end=row.get("is_end"),
has_siding_track=row.get("has_siding_track"),
has_signal_room=row.get("has_signal_room")
)
stations.append(station)
logger.info(f"Supabase에서 {len(stations)}개 역 정보 조회 완료")
return stations
# ========================================================================
# 제조사 정보 (Manufacturer)
# ========================================================================
def get_manufacturers(self) -> List[ManufacturerInfo]:
"""
Supabase에서 제조사 정보 조회
Returns:
제조사 정보 리스트
"""
data = self._request("GET", "Manufacturer", params={"order": "id.asc"})
if not data:
return []
manufacturers = []
for row in data:
mfr = ManufacturerInfo(
id=row.get("id"),
manufact=row.get("manufact")
)
manufacturers.append(mfr)
logger.info(f"Supabase에서 {len(manufacturers)}개 제조사 정보 조회 완료")
return manufacturers
# ========================================================================
# 고장 코드 정보 (Fault_Code_Table)
# ========================================================================
def get_fault_codes(self, car_type: str = None, manufacturer: str = None) -> List[FaultCodeInfo]:
"""
Supabase에서 고장 코드 조회
Args:
car_type: 차량 타입 필터
manufacturer: 제조사 필터
Returns:
고장 코드 리스트
"""
params = {"order": "f_code_num.asc"}
if car_type:
params["car_type"] = f"eq.{car_type}"
if manufacturer:
params["manufacturer"] = f"eq.{manufacturer}"
data = self._request("GET", "Fault_Code_Table", params=params)
if not data:
return []
codes = []
for row in data:
code = FaultCodeInfo(
id=row.get("id"),
f_code=row.get("f_code"),
f_code_num=row.get("f_code_num"),
f_name=row.get("f_name"),
car_type=row.get("car_type"),
f_class=row.get("f_class"),
fault_name=row.get("fault_name"),
grade=row.get("grade"),
device=row.get("device"),
fault_detail=row.get("fault_detail"),
fault_reaction=row.get("fault_reaction"),
fault_action=row.get("fault_action"),
alias_name=row.get("alias_name"),
manufacturer=row.get("manufacturer")
)
codes.append(code)
logger.info(f"Supabase에서 {len(codes)}개 고장 코드 조회 완료")
return codes
# ========================================================================
# 연결 테스트
# ========================================================================
def test_connection(self) -> bool:
"""
Supabase 연결 테스트
Returns:
연결 성공 여부
"""
try:
# 간단한 쿼리로 연결 테스트
data = self._request("GET", "Manufacturer", params={"limit": "1"})
if data is not None:
logger.info("Supabase 연결 테스트 성공")
return True
return False
except Exception as e:
logger.error(f"Supabase 연결 테스트 실패: {e}")
return False
# ============================================================================
# 동기화 매니저
# ============================================================================
class SupabaseSyncManager:
"""
Supabase 데이터 동기화 매니저
Supabase에서 마스터 데이터를 가져와 로컬 SQLite DB에 저장합니다.
"""
def __init__(self):
self.client = SupabaseClient()
self.settings = get_settings_manager()
def sync_trains(self) -> int:
"""
편성 정보 동기화
Returns:
동기화된 레코드
"""
logger.info("편성 정보 동기화 시작...")
try:
# Supabase에서 데이터 조회
supabase_trains = self.client.get_trains()
if not supabase_trains:
logger.warning("Supabase에서 편성 정보를 가져오지 못했습니다.")
return 0
# 로컬 DB에 저장
count = 0
for train in supabase_trains:
# 편성번호 생성 (train_set 또는 train_id 사용)
train_number = str(train.train_set) if train.train_set else train.train_id
if not train_number:
continue
# TrainInfo 객체 생성
train_info = TrainInfo(
train_number=train_number,
train_type="B" if train.is_new else "A", # 신형=B, 구형=A
manufacturer=train.manufacturer or "",
manufacture_year=0, # 도입일에서 추출 가능
depot="", # Supabase에 없음
is_active=True
)
# 도입일에서 년도 추출
if train.date_of_Introduction:
try:
year = int(train.date_of_Introduction[:4])
train_info.manufacture_year = year
except (ValueError, TypeError):
pass
self.settings.save_train_info(train_info)
count += 1
# 동기화 상태 업데이트
self.settings.update_sync_status("train_info", "synced")
logger.info(f"편성 정보 동기화 완료: {count}")
return count
except Exception as e:
logger.error(f"편성 정보 동기화 실패: {e}")
self.settings.update_sync_status("train_info", "error")
return 0
def sync_stations(self, line_number: int = 1) -> int:
"""
정보 동기화
Args:
line_number: 호선 (기본값: 1호선)
Returns:
동기화된 레코드
"""
logger.info(f"{line_number}호선 역 정보 동기화 시작...")
try:
# Supabase에서 데이터 조회
supabase_stations = self.client.get_stations(line_number)
if not supabase_stations:
logger.warning("Supabase에서 역 정보를 가져오지 못했습니다.")
return 0
# 로컬 DB에 저장
count = 0
for station in supabase_stations:
if not station.station_name:
continue
# StationInfo 객체 생성
station_info = StationInfo(
station_code=str(int(station.station_id)) if station.station_id else "",
station_name=station.station_name,
line_number=station.line_number or line_number,
order=int(station.station_id) if station.station_id else 0,
is_active=True
)
self.settings.save_station_info(station_info)
count += 1
# 동기화 상태 업데이트
self.settings.update_sync_status("station_info", "synced")
logger.info(f"역 정보 동기화 완료: {count}")
return count
except Exception as e:
logger.error(f"역 정보 동기화 실패: {e}")
self.settings.update_sync_status("station_info", "error")
return 0
def sync_manufacturers(self) -> int:
"""
제조사 정보 동기화
Returns:
동기화된 레코드
"""
logger.info("제조사 정보 동기화 시작...")
try:
# Supabase에서 데이터 조회
supabase_manufacturers = self.client.get_manufacturers()
if not supabase_manufacturers:
logger.warning("Supabase에서 제조사 정보를 가져오지 못했습니다.")
return 0
# 로컬 DB에 저장
count = 0
for mfr in supabase_manufacturers:
if not mfr.manufact:
continue
# ManufacturerInfo 객체 생성
manufacturer_info = ManufacturerInfo(
id=mfr.id,
name=mfr.manufact
)
self.settings.save_manufacturer_info(manufacturer_info)
count += 1
# 동기화 상태 업데이트
self.settings.update_sync_status("manufacturer_info", "synced")
logger.info(f"제조사 정보 동기화 완료: {count}")
return count
except Exception as e:
logger.error(f"제조사 정보 동기화 실패: {e}")
self.settings.update_sync_status("manufacturer_info", "error")
return 0
def sync_fault_codes(self, car_type: str = None, manufacturer: str = None) -> int:
"""
고장 코드 동기화
Args:
car_type: 차량 타입 필터
manufacturer: 제조사 필터
Returns:
동기화된 레코드
"""
logger.info("고장 코드 동기화 시작...")
try:
# Supabase에서 데이터 조회
supabase_codes = self.client.get_fault_codes(car_type, manufacturer)
if not supabase_codes:
logger.warning("Supabase에서 고장 코드를 가져오지 못했습니다.")
return 0
# 로컬 DB에 저장
count = 0
for code in supabase_codes:
if not code.f_code:
continue
# FaultCodeInfo 객체 생성
fault_code_info = FaultCodeInfo(
f_code=code.f_code,
f_code_num=code.f_code_num,
f_name=code.f_name,
car_type=code.car_type,
f_class=code.f_class,
fault_name=code.fault_name,
grade=code.grade,
device=code.device,
fault_detail=code.fault_detail,
fault_action=code.fault_action,
manufacturer=code.manufacturer
)
self.settings.save_fault_code_info(fault_code_info)
count += 1
# 동기화 상태 업데이트
self.settings.update_sync_status("fault_code_info", "synced")
logger.info(f"고장 코드 동기화 완료: {count}")
return count
except Exception as e:
logger.error(f"고장 코드 동기화 실패: {e}")
self.settings.update_sync_status("fault_code_info", "error")
return 0
def sync_all(self) -> Dict[str, int]:
"""
모든 마스터 데이터 동기화
Returns:
테이블별 동기화 결과 {테이블명: 레코드수}
"""
results = {}
# 편성 정보
results["trains"] = self.sync_trains()
# 역 정보 (1호선)
results["stations"] = self.sync_stations(line_number=1)
# 제조사 정보
results["manufacturers"] = self.sync_manufacturers()
# 고장 코드 (많으면 시간이 걸리므로 선택적으로 호출)
# results["fault_codes"] = self.sync_fault_codes()
return results
# ============================================================================
# 모듈 레벨 함수
# ============================================================================
def get_supabase_client() -> SupabaseClient:
"""Supabase 클라이언트 인스턴스 반환"""
return SupabaseClient()
def sync_master_data() -> Dict[str, int]:
"""마스터 데이터 동기화 실행"""
sync_manager = SupabaseSyncManager()
return sync_manager.sync_all()

340
core/train_parser.py Normal file
View File

@ -0,0 +1,340 @@
# -*- coding: utf-8 -*-
"""
열차 정보 파싱 모듈
열번과 역명에서 발생 시간을 유추하고,
입력된 정보를 파싱하는 기능을 제공합니다.
기능:
- 열번에서 열차 종별, 방향 추출
- 열번과 역명으로 발생 시간 추정
- 고장 정보 자동 파싱
"""
import re
from datetime import date, time, datetime
from typing import Optional, Dict, Tuple, List, Any
from dataclasses import dataclass
from core.logger import get_logger
logger = get_logger(__name__)
@dataclass
class ColumnNumberInfo:
"""열번 정보"""
raw: str # 원본 열번
train_type: str # 열차 종별 (정기, 회송, 시운전, 구간, 임시)
direction: str # 방향 (상행, 하행)
sequence: int # 순번
is_valid: bool # 유효 여부
@dataclass
class ParsedFaultInfo:
"""파싱된 고장 정보"""
occurrence_date: Optional[date] = None
occurrence_time: Optional[time] = None
column_number: str = ""
train_number: str = ""
car_number: str = ""
occurrence_station: str = ""
device_category: str = ""
fault_code: str = ""
fault_content: str = ""
action_content: str = ""
fault_source: str = ""
raw_text: str = ""
class TrainParser:
"""
열차 정보 파싱 클래스
열번에서 열차 종별과 방향을 추출하고,
열번과 역명으로 발생 시간을 추정합니다.
"""
# 1호선 열차 종별 (1000자리)
TRAIN_TYPES = {
1: ("정기", "up"), # 상행 정기
2: ("정기", "down"), # 하행 정기
3: ("회송", "up"), # 상행 회송
4: ("회송", "down"), # 하행 회송
5: ("시운전", "up"), # 상행 시운전
6: ("시운전", "down"), # 하행 시운전
7: ("구간", "up"), # 상행 구간
8: ("구간", "down"), # 하행 구간
9: ("임시", "both"), # 임시
}
# 방향 판단 (1자리: 홀수=상행, 짝수=하행)
@staticmethod
def get_direction_from_last_digit(digit: int) -> str:
"""마지막 자리로 방향 판단"""
return "up" if digit % 2 == 1 else "down"
def __init__(self):
"""초기화"""
self._crud = None
@property
def crud(self):
"""CRUD 매니저 (지연 로딩)"""
if self._crud is None:
from database.crud import get_crud
self._crud = get_crud()
return self._crud
def parse_column_number(self, column_number: str) -> ColumnNumberInfo:
"""
열번 파싱
Args:
column_number: 열번 (: "1001", "2034", "3511")
Returns:
ColumnNumberInfo: 열번 정보
Examples:
>>> parser = TrainParser()
>>> info = parser.parse_column_number("1001")
>>> print(info.train_type, info.direction)
정기 상행
"""
column_number = column_number.strip()
# 4자리 숫자 검증
if not column_number or not column_number.isdigit():
return ColumnNumberInfo(
raw=column_number,
train_type="",
direction="",
sequence=0,
is_valid=False
)
if len(column_number) != 4:
return ColumnNumberInfo(
raw=column_number,
train_type="",
direction="",
sequence=0,
is_valid=False
)
# 각 자리 추출
d1000 = int(column_number[0]) # 1000자리: 열차 종별
d100 = int(column_number[1]) # 100자리
d10 = int(column_number[2]) # 10자리
d1 = int(column_number[3]) # 1자리: 방향 판단
# 열차 종별
train_type_info = self.TRAIN_TYPES.get(d1000, ("알수없음", "unknown"))
train_type = train_type_info[0]
# 방향 (1자리 기준)
direction = self.get_direction_from_last_digit(d1)
direction_text = "상행" if direction == "up" else "하행"
# 순번 (100자리 + 10자리)
sequence = d100 * 10 + d10
return ColumnNumberInfo(
raw=column_number,
train_type=train_type,
direction=direction_text,
sequence=sequence,
is_valid=True
)
def estimate_time(
self,
column_number: str,
station: str,
occurrence_date: Optional[date] = None
) -> Optional[time]:
"""
열번과 역명으로 발생 시간 추정
Args:
column_number: 열번
station: 역명
occurrence_date: 발생일 (평일/주말 판단용)
Returns:
추정 시간 또는 None
"""
if not column_number or not station:
return None
try:
return self.crud.estimate_time_by_column_station(
column_number, station, occurrence_date
)
except Exception as e:
logger.warning(f"시간 추정 실패: {column_number}, {station} - {e}")
return None
def parse_train_number(self, train_number: str) -> Tuple[int, str]:
"""
편성번호 파싱
Args:
train_number: 편성번호 (: "132B", "101A")
Returns:
(숫자 부분, 접미사) 튜플
Examples:
>>> parser = TrainParser()
>>> num, suffix = parser.parse_train_number("132B")
>>> print(num, suffix)
132 B
"""
train_number = train_number.strip().upper()
if not train_number:
return 0, ""
# 숫자와 접미사 분리
match = re.match(r'^(\d+)([AB])?$', train_number)
if match:
num = int(match.group(1))
suffix = match.group(2) or ""
return num, suffix
return 0, ""
def format_train_number(self, num: int, suffix: str = "") -> str:
"""
편성번호 포맷팅
Args:
num: 숫자 부분 (: 132)
suffix: 접미사 (: "A", "B")
Returns:
포맷된 편성번호 (: "132B")
"""
if num <= 0:
return ""
if suffix:
return f"{num}{suffix}"
return str(num)
def get_train_display(self, train_number: str) -> str:
"""
편성번호 표시용 텍스트 반환
테이블에서 중간 2자리만 표시할 사용
Args:
train_number: 편성번호 (: "132B")
Returns:
표시용 텍스트 (: "32")
"""
if not train_number:
return ""
# 숫자 부분에서 마지막 2자리 추출
match = re.match(r'^\d*(\d{2})[AB]?$', train_number.strip())
if match:
return match.group(1)
return train_number
def parse_fault_text(self, text: str) -> ParsedFaultInfo:
"""
고장 텍스트 자동 파싱
복사/붙여넣기 고장 정보를 파싱하여 필드로 분리합니다.
Args:
text: 고장 정보 텍스트
Returns:
ParsedFaultInfo: 파싱된 고장 정보
"""
result = ParsedFaultInfo(raw_text=text)
if not text:
return result
lines = text.strip().split('\n')
# 날짜 패턴 찾기
date_pattern = r'(\d{4}[-./]\d{2}[-./]\d{2}|\d{2}[-./]\d{2}[-./]\d{2})'
time_pattern = r'(\d{2}:\d{2}(?::\d{2})?)'
for line in lines:
# 날짜 추출
date_match = re.search(date_pattern, line)
if date_match and not result.occurrence_date:
date_str = date_match.group(1).replace('/', '-').replace('.', '-')
try:
if len(date_str) == 8: # YY-MM-DD
result.occurrence_date = datetime.strptime(date_str, "%y-%m-%d").date()
else: # YYYY-MM-DD
result.occurrence_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
pass
# 시간 추출
time_match = re.search(time_pattern, line)
if time_match and not result.occurrence_time:
time_str = time_match.group(1)
try:
if len(time_str) == 5: # HH:MM
result.occurrence_time = datetime.strptime(time_str, "%H:%M").time()
else: # HH:MM:SS
result.occurrence_time = datetime.strptime(time_str, "%H:%M:%S").time()
except ValueError:
pass
# 편성번호 추출 (예: 132B, 101A)
train_match = re.search(r'\b(1\d{2}[AB])\b', line)
if train_match and not result.train_number:
result.train_number = train_match.group(1)
# 열번 추출 (4자리 숫자)
column_match = re.search(r'\b([1-9]\d{3})\b', line)
if column_match and not result.column_number:
result.column_number = column_match.group(1)
# 호차 추출 (1~8)
car_match = re.search(r'\b([1-8])호차?\b', line)
if car_match and not result.car_number:
result.car_number = car_match.group(1)
# 역명 추출 (xx역)
station_match = re.search(r'([가-힣]+역)', line)
if station_match and not result.occurrence_station:
result.occurrence_station = station_match.group(1)
# 나머지 텍스트는 고장내용으로
if not result.fault_content and lines:
result.fault_content = '\n'.join(lines)
return result
# 싱글톤 인스턴스
_parser_instance: Optional[TrainParser] = None
def get_train_parser() -> TrainParser:
"""
TrainParser 싱글톤 인스턴스 반환
Returns:
TrainParser 인스턴스
"""
global _parser_instance
if _parser_instance is None:
_parser_instance = TrainParser()
return _parser_instance

BIN
data/common_data.db Normal file

Binary file not shown.

111
data/db_to_json.py Normal file
View File

@ -0,0 +1,111 @@
import pandas as pd
import json
def load_and_convert_excel(file_path, sheet_name, day_type):
"""
엑셀 시트를 읽고 Long 형식으로 변환합니다.
Args:
file_path: 엑셀 파일 경로
sheet_name: 시트 이름 (weekday, saturday, sunday)
day_type: 요일 구분 (평일, 토요일, 일요일/공휴일)
Returns:
변환된 DataFrame
"""
# 엑셀 시트 읽기
df = pd.read_excel(file_path, sheet_name=sheet_name)
# 불필요한 컬럼 제거 (Unnamed: 0, 기지, 열번.1)
# 기지 컬럼은 역이 아니므로 제외
drop_cols = ['Unnamed: 0', '기지', '열번.1']
df = df.drop(columns=[col for col in drop_cols if col in df.columns], errors='ignore')
# 고정할 컬럼 (메타 정보)
id_vars = ['상하', '입출고', 'DIA', '열번']
# 역명 컬럼 (id_vars 이후 모든 컬럼)
station_columns = [col for col in df.columns if col not in id_vars]
# Wide to Long 변환 (Melt)
df_long = pd.melt(
df,
id_vars=id_vars,
value_vars=station_columns,
var_name='역명',
value_name='시간'
)
# 시간 데이터가 없는 행(정차하지 않거나 데이터 없음) 제거
df_long = df_long.dropna(subset=['시간'])
df_long = df_long[df_long['시간'].astype(str).str.strip() != '']
# 요일 구분 추가
df_long['요일구분'] = day_type
# 입출고가 NaN인 경우 빈 문자열로 변환
df_long['입출고'] = df_long['입출고'].fillna('')
# 데이터 타입 정리
df_long['DIA'] = df_long['DIA'].astype(int)
df_long['열번'] = df_long['열번'].astype(int)
return df_long
def convert_dia_to_json(excel_path, output_path=None):
"""
dia.xlsx 파일의 모든 시트를 읽어 JSON으로 변환합니다.
Args:
excel_path: dia.xlsx 파일 경로
output_path: 저장할 JSON 파일 경로 (None이면 저장하지 않음)
Returns:
변환된 전체 데이터 (list of dict)
"""
# 시트별 요일구분 매핑
sheet_mapping = {
'weekday': '평일',
'saturday': '토요일',
'sunday': '일요일/공휴일'
}
all_data = []
for sheet_name, day_type in sheet_mapping.items():
print(f"처리 중: {sheet_name} ({day_type})")
df_long = load_and_convert_excel(excel_path, sheet_name, day_type)
all_data.append(df_long)
print(f" - {len(df_long)}개 레코드 변환 완료")
# 모든 데이터 합치기
df_combined = pd.concat(all_data, ignore_index=True)
# 컬럼 순서 정리
column_order = ['상하', '입출고', 'DIA', '열번', '역명', '시간', '요일구분']
df_combined = df_combined[column_order]
# JSON 변환
json_data = df_combined.to_dict(orient='records')
# 파일 저장
if output_path:
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(json_data, f, ensure_ascii=False, indent=4)
print(f"\nJSON 파일 저장 완료: {output_path}")
print(f"{len(json_data)}개 레코드")
return json_data
if __name__ == "__main__":
# 실행
excel_file = "dia.xlsx"
output_file = "dia_data.json"
result = convert_dia_to_json(excel_file, output_file)
# 결과 샘플 출력
print("\n=== 변환 결과 샘플 (처음 5개) ===")
print(json.dumps(result[:5], ensure_ascii=False, indent=4))

BIN
data/dia.xlsx Normal file

Binary file not shown.

368363
data/dia_data.json Normal file

File diff suppressed because it is too large Load Diff

169
data/fault_data.json Normal file
View File

@ -0,0 +1,169 @@
{
"train_numbers": [
"101B",
"102B",
"103B",
"104B",
"105B",
"106B",
"107B",
"108B",
"109B",
"110B",
"111B",
"112B",
"113B",
"114B",
"115B",
"116B",
"117B",
"118B",
"119B",
"120B",
"121A",
"122B",
"123B",
"124B",
"125A",
"126B",
"127B",
"128B",
"129B",
"130A",
"131A",
"132B",
"133A",
"134A",
"135B",
"136B",
"137B",
"138B",
"139A",
"140B",
"141B",
"142A",
"143B",
"144B",
"145A",
"146A",
"147A",
"148A",
"149A",
"150A",
"151A"
],
"stations": [
"신평역",
"하단역",
"당리역",
"사하역",
"괴정역",
"대티역",
"서대신역",
"동대신역",
"토성역",
"자갈치역",
"남포역",
"중앙역",
"부산역",
"초량역",
"부산진역",
"좌천역",
"범일역",
"범내골역",
"서면역",
"부암역",
"가야역",
"동의대역",
"개금역",
"냉정역",
"주례역",
"감전역",
"사상역",
"덕포역",
"모덕역",
"모라역",
"구남역",
"구명역",
"덕천역",
"수정역",
"화명역",
"율리역",
"동원역",
"금곡역",
"호포역",
"증산역",
"부산대역",
"장전역",
"남산역",
"만덕역",
"미남역",
"사직역",
"종합운동장역",
"거제역",
"연산역",
"시청역",
"양정역",
"부전역",
"서면역",
"범내골역",
"범일역"
],
"column_numbers": [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10"
],
"device_categories": [
"추진장치",
"제동장치",
"출입문",
"냉난방",
"조명",
"방송",
"ATC/ATO",
"집전장치",
"차체",
"대차",
"기타"
],
"fault_codes": [
"F001",
"F002",
"F003",
"F004",
"F005",
"F006",
"F007",
"F008",
"F009",
"F010",
"F011",
"F012",
"F013",
"F014",
"F015",
"F016",
"F017",
"F018",
"F019",
"F020",
"F021",
"F022",
"F023",
"F024",
"F025",
"F026",
"F027",
"F028",
"F029",
"F030"
]
}

View File

@ -0,0 +1,384 @@
{
"1팀": {
"지시": [
{
"name": "created_date",
"visible": true,
"width": 100,
"display_format": "full"
},
{
"name": "created_team",
"visible": true,
"width": 70,
"display_format": null
},
{
"name": "instructor",
"visible": true,
"width": 80,
"display_format": null
},
{
"name": "instruction_content",
"visible": true,
"width": 300,
"display_format": null
},
{
"name": "instruction_date",
"visible": true,
"width": 100,
"display_format": "full"
},
{
"name": "is_continuous",
"visible": true,
"width": 50,
"display_format": null
},
{
"name": "team_confirmations",
"visible": true,
"width": 120,
"display_format": null
},
{
"name": "is_completed",
"visible": true,
"width": 50,
"display_format": null
}
],
"고장": [
{
"name": "occurrence_date",
"visible": true,
"width": 70,
"display_format": "full"
},
{
"name": "column_number",
"visible": true,
"width": 60,
"display_format": null
},
{
"name": "train_number",
"visible": true,
"width": 50,
"display_format": null
},
{
"name": "car_number",
"visible": true,
"width": 50,
"display_format": null
},
{
"name": "fault_code",
"visible": false,
"width": 70,
"display_format": null
},
{
"name": "device_category",
"visible": true,
"width": 110,
"display_format": null
},
{
"name": "occurrence_station",
"visible": true,
"width": 90,
"display_format": null
},
{
"name": "occurrence_time",
"visible": false,
"width": 90,
"display_format": null
},
{
"name": "fault_content",
"visible": true,
"width": 255,
"display_format": null
},
{
"name": "action_content",
"visible": true,
"width": 224,
"display_format": null
},
{
"name": "fault_source",
"visible": true,
"width": 98,
"display_format": null
},
{
"name": "attachments",
"visible": true,
"width": 72,
"display_format": null
},
{
"name": "action_team",
"visible": true,
"width": 72,
"display_format": null
},
{
"name": "team_confirmations",
"visible": true,
"width": 77,
"display_format": null
},
{
"name": "is_completed",
"visible": true,
"width": 62,
"display_format": null
}
],
"작업": [
{
"name": "work_date",
"visible": true,
"width": 100,
"display_format": "full"
},
{
"name": "work_entity",
"visible": true,
"width": 100,
"display_format": null
},
{
"name": "target_train",
"visible": true,
"width": 80,
"display_format": null
},
{
"name": "target_device",
"visible": true,
"width": 100,
"display_format": null
},
{
"name": "work_content",
"visible": true,
"width": 250,
"display_format": null
},
{
"name": "remarks",
"visible": true,
"width": 150,
"display_format": null
},
{
"name": "team_confirmations",
"visible": true,
"width": 120,
"display_format": null
},
{
"name": "is_completed",
"visible": true,
"width": 50,
"display_format": null
}
],
"기타": [
{
"name": "created_date",
"visible": true,
"width": 100,
"display_format": "full"
},
{
"name": "created_team",
"visible": true,
"width": 70,
"display_format": null
},
{
"name": "reporter",
"visible": true,
"width": 80,
"display_format": null
},
{
"name": "report_content",
"visible": true,
"width": 300,
"display_format": null
},
{
"name": "remarks",
"visible": true,
"width": 150,
"display_format": null
},
{
"name": "related_document",
"visible": true,
"width": 100,
"display_format": null
},
{
"name": "team_confirmations",
"visible": true,
"width": 120,
"display_format": null
},
{
"name": "is_completed",
"visible": true,
"width": 50,
"display_format": null
}
]
},
"2팀": {
"지시": [
{
"name": "created_date",
"visible": true,
"width": 100,
"display_format": null
},
{
"name": "created_team",
"visible": true,
"width": 70,
"display_format": null
},
{
"name": "instructor",
"visible": true,
"width": 80,
"display_format": null
},
{
"name": "instruction_content",
"visible": true,
"width": 608,
"display_format": null
},
{
"name": "instruction_date",
"visible": true,
"width": 100,
"display_format": null
},
{
"name": "is_continuous",
"visible": true,
"width": 50,
"display_format": null
},
{
"name": "team_confirmations",
"visible": true,
"width": 120,
"display_format": null
},
{
"name": "is_completed",
"visible": true,
"width": 410,
"display_format": null
}
],
"고장": [
{
"name": "occurrence_date",
"visible": true,
"width": 69,
"display_format": "month_day"
},
{
"name": "column_number",
"visible": true,
"width": 65,
"display_format": null
},
{
"name": "train_number",
"visible": true,
"width": 56,
"display_format": null
},
{
"name": "car_number",
"visible": true,
"width": 50,
"display_format": null
},
{
"name": "fault_code",
"visible": true,
"width": 88,
"display_format": null
},
{
"name": "device_category",
"visible": true,
"width": 110,
"display_format": null
},
{
"name": "occurrence_station",
"visible": true,
"width": 90,
"display_format": null
},
{
"name": "occurrence_time",
"visible": false,
"width": 90,
"display_format": null
},
{
"name": "fault_content",
"visible": true,
"width": 300,
"display_format": null
},
{
"name": "action_content",
"visible": true,
"width": 154,
"display_format": null
},
{
"name": "fault_source",
"visible": true,
"width": 100,
"display_format": null
},
{
"name": "action_team",
"visible": true,
"width": 73,
"display_format": null
},
{
"name": "team_confirmations",
"visible": true,
"width": 73,
"display_format": null
},
{
"name": "is_completed",
"visible": true,
"width": 313,
"display_format": null
}
]
}
}

BIN
data/handover.db Normal file

Binary file not shown.

BIN
data/settings.db Normal file

Binary file not shown.

3492
data/weather_debug.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
2026-01-18T08:35:19.150599

23
database/__init__.py Normal file
View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""
Database 모듈 패키지
데이터베이스 관련 기능을 제공하는 모듈들의 집합
패키지는 다음을 포함합니다:
- db_manager: 데이터베이스 연결 관리
- models: 데이터 모델 정의
- crud: CRUD 연산
- migrations: 데이터베이스 마이그레이션
- sync_manager: 원격 DB 동기화 (추후 구현)
"""
from .db_manager import DatabaseManager
from .models import *
from .crud import CRUDManager
__all__ = [
'DatabaseManager',
'CRUDManager',
]

View File

@ -0,0 +1,515 @@
# -*- coding: utf-8 -*-
"""
공통 데이터베이스 관리 모듈
변경이 거의 없는 공통 데이터(편성, 시각표, 고장코드, MMI코드, 시그널, 역명, 도면약어 ) 관리합니다.
"""
import sqlite3
import threading
from pathlib import Path
from typing import Optional, List, Dict, Any
from contextlib import contextmanager
from core.constants import DATA_DIR
from core.logger import get_logger
from core.exceptions import DatabaseConnectionError, DatabaseQueryError
logger = get_logger(__name__)
# 공통 데이터베이스 파일 경로
COMMON_DB_FILE = DATA_DIR / "common_data.db"
# ============================================================================
# 공통 데이터 테이블 SQL 스키마
# ============================================================================
CREATE_COMMON_TABLES_SQL = """
-- 전동차 편성 테이블
CREATE TABLE IF NOT EXISTS train_formations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
train_number TEXT UNIQUE NOT NULL,
is_new_train INTEGER DEFAULT 1,
manufacturer TEXT,
introduction_date DATE,
depot TEXT,
alias TEXT,
introduction_stage TEXT,
introduction_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 열차 다이아 시각표 테이블
CREATE TABLE IF NOT EXISTS train_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
column_number TEXT NOT NULL,
station TEXT NOT NULL,
arrival_time TIME,
departure_time TIME,
direction TEXT NOT NULL DEFAULT 'up',
is_weekday INTEGER DEFAULT 1,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(column_number, station, direction, is_weekday)
);
-- 고장코드 테이블
CREATE TABLE IF NOT EXISTS fault_codes (
id TEXT PRIMARY KEY,
f_code TEXT NOT NULL,
f_code_num TEXT,
f_name TEXT NOT NULL,
car_type TEXT,
f_class TEXT,
fault_name TEXT,
grade TEXT,
device TEXT,
fault_detail TEXT,
fault_reaction TEXT,
fault_detection TEXT,
fault_clear TEXT,
fault_action TEXT,
fault_schematics TEXT,
car_id TEXT,
alias_name TEXT,
manufacturer TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- MMI 코드 테이블
CREATE TABLE IF NOT EXISTS mmi_codes (
id TEXT PRIMARY KEY,
code_name TEXT NOT NULL,
code_description TEXT,
data_type TEXT,
car_id TEXT,
alias_name TEXT,
manufacturer TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 시그널 코드 테이블
CREATE TABLE IF NOT EXISTS signals (
id TEXT PRIMARY KEY,
sig_num TEXT NOT NULL,
signal_abbreviation TEXT NOT NULL,
signal_description TEXT,
status_value TEXT,
manufacturer TEXT,
classification TEXT,
original_data TEXT,
alias_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 역명 테이블
CREATE TABLE IF NOT EXISTS stations (
id TEXT PRIMARY KEY,
line_number TEXT NOT NULL,
station_id TEXT NOT NULL,
station_name TEXT NOT NULL,
station_map TEXT,
is_underground INTEGER DEFAULT 0,
is_island INTEGER DEFAULT 0,
is_exchange INTEGER DEFAULT 0,
is_end INTEGER DEFAULT 0,
has_siding_track INTEGER DEFAULT 0,
has_signal_room INTEGER DEFAULT 0,
etc1 TEXT,
etc2 TEXT,
etc3 TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(line_number, station_id)
);
-- 도면약어 테이블
CREATE TABLE IF NOT EXISTS drawer_abbreviations (
id TEXT PRIMARY KEY,
abb TEXT NOT NULL,
classification TEXT,
related_drawings TEXT,
drawing_id TEXT,
manufacturer TEXT,
term TEXT,
pages TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_fault_codes_f_code ON fault_codes(f_code);
CREATE INDEX IF NOT EXISTS idx_fault_codes_device ON fault_codes(device);
CREATE INDEX IF NOT EXISTS idx_mmi_codes_code_name ON mmi_codes(code_name);
CREATE INDEX IF NOT EXISTS idx_signals_sig_num ON signals(sig_num);
CREATE INDEX IF NOT EXISTS idx_signals_abbreviation ON signals(signal_abbreviation);
CREATE INDEX IF NOT EXISTS idx_stations_station_name ON stations(station_name);
CREATE INDEX IF NOT EXISTS idx_drawer_abbreviations_abb ON drawer_abbreviations(abb);
"""
# ============================================================================
# 공통 데이터베이스 관리자 클래스
# ============================================================================
class CommonDatabaseManager:
"""
공통 데이터베이스 관리자
변경이 거의 없는 공통 데이터를 관리하는 별도의 데이터베이스입니다.
"""
_instance = None
_lock = threading.Lock()
_initialized = False
def __new__(cls, db_path: Path = None):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, db_path: Path = None):
"""공통 데이터베이스 관리자 초기화"""
if self._initialized:
return
self.db_path = db_path or COMMON_DB_FILE
self._local = threading.local()
# 데이터 디렉토리 생성
DATA_DIR.mkdir(parents=True, exist_ok=True)
# 데이터베이스 초기화
self._initialize_database()
self._initialized = True
logger.info(f"공통 데이터베이스 관리자 초기화 완료: {self.db_path}")
def _initialize_database(self):
"""데이터베이스 초기화 (테이블 생성)"""
try:
with self.get_connection() as conn:
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(CREATE_COMMON_TABLES_SQL)
conn.commit()
logger.info("공통 데이터베이스 테이블 초기화 완료")
except Exception as e:
logger.error(f"공통 데이터베이스 초기화 실패: {e}")
raise DatabaseConnectionError(f"공통 데이터베이스 초기화 실패: {e}")
@contextmanager
def get_connection(self):
"""데이터베이스 연결 컨텍스트 매니저"""
if not hasattr(self._local, 'connection') or self._local.connection is None:
try:
self._local.connection = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
timeout=30.0
)
self._local.connection.row_factory = sqlite3.Row
logger.debug(f"공통 데이터베이스 연결 생성: {self.db_path}")
except sqlite3.Error as e:
logger.error(f"공통 데이터베이스 연결 실패: {e}")
raise DatabaseConnectionError(f"공통 데이터베이스 연결 실패: {e}")
try:
yield self._local.connection
except sqlite3.Error as e:
logger.error(f"공통 데이터베이스 쿼리 오류: {e}")
raise DatabaseQueryError(f"공통 데이터베이스 쿼리 오류: {e}")
finally:
# 연결은 스레드별로 유지하되, 필요시 닫을 수 있도록
pass
def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
"""SQL 실행"""
with self.get_connection() as conn:
return conn.execute(sql, params or ())
def executemany(self, sql: str, params_list: List[tuple]) -> sqlite3.Cursor:
"""여러 SQL 실행"""
with self.get_connection() as conn:
return conn.executemany(sql, params_list)
def fetch_one(self, sql: str, params: tuple = ()) -> Optional[Dict[str, Any]]:
"""단일 행 조회"""
with self.get_connection() as conn:
cursor = conn.execute(sql, params or ())
row = cursor.fetchone()
return dict(row) if row else None
def fetch_all(self, sql: str, params: tuple = ()) -> List[Dict[str, Any]]:
"""모든 행 조회"""
with self.get_connection() as conn:
cursor = conn.execute(sql, params or ())
return [dict(row) for row in cursor.fetchall()]
def commit(self):
"""커밋"""
with self.get_connection() as conn:
conn.commit()
def load_data_from_sql_files(self, sql_dir: Path = None):
"""SQL 파일에서 데이터 로드"""
from database.sql_loader import load_sql_file
if sql_dir is None:
sql_dir = Path(__file__).parent.parent / "ori_data"
if not sql_dir.exists():
logger.warning(f"SQL 파일 디렉토리가 없습니다: {sql_dir}")
return
# 각 SQL 파일 처리
sql_files = {
"Fault_Code_Table_rows.sql": self._load_fault_codes,
"MMI_Code_rows.sql": self._load_mmi_codes,
"Signals_rows.sql": self._load_signals,
"Stations_rows.sql": self._load_stations,
"drawer_abbreviation_rows.sql": self._load_drawer_abbreviations,
}
for filename, loader_func in sql_files.items():
sql_file = sql_dir / filename
if sql_file.exists():
try:
logger.info(f"데이터 로드 중: {filename}")
records = load_sql_file(sql_file)
if records:
loader_func(records)
logger.info(f"데이터 로드 완료: {filename} ({len(records)}개 레코드)")
else:
logger.warning(f"파싱된 레코드가 없습니다: {filename}")
except Exception as e:
logger.error(f"데이터 로드 실패 ({filename}): {e}", exc_info=True)
else:
logger.warning(f"SQL 파일을 찾을 수 없습니다: {sql_file}")
def _load_fault_codes(self, records: List[Dict[str, Any]]):
"""고장코드 데이터 삽입"""
if not records:
return
insert_sql = """
INSERT OR REPLACE INTO fault_codes
(id, f_code, f_code_num, f_name, car_type, f_class, fault_name, grade, device,
fault_detail, fault_reaction, fault_detection, fault_clear, fault_action,
fault_schematics, car_id, alias_name, manufacturer, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
params_list = []
for record in records:
# datetime 문자열 파싱
created_at = self._parse_datetime(record.get('created_at'))
updated_at = self._parse_datetime(record.get('updated_at'))
params_list.append((
record.get('id'),
record.get('f_code'),
record.get('f_code_num'),
record.get('f_name'),
record.get('car_type'),
record.get('f_class'),
record.get('fault_name'),
record.get('grade'),
record.get('device'),
record.get('fault_detail'),
record.get('fault_reaction'),
record.get('fault_detection'),
record.get('fault_clear'),
record.get('fault_action'),
record.get('fault_schematics'),
record.get('car_id'),
record.get('alias_name'),
record.get('manufacturer'),
created_at,
updated_at
))
self.executemany(insert_sql, params_list)
self.commit()
logger.info(f"고장코드 데이터 삽입 완료: {len(params_list)}")
def _load_mmi_codes(self, records: List[Dict[str, Any]]):
"""MMI 코드 데이터 삽입"""
if not records:
return
insert_sql = """
INSERT OR REPLACE INTO mmi_codes
(id, code_name, code_description, data_type, car_id, alias_name, manufacturer, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
params_list = []
for record in records:
created_at = self._parse_datetime(record.get('created_at'))
updated_at = self._parse_datetime(record.get('updated_at'))
params_list.append((
record.get('id'),
record.get('code_name'),
record.get('code_description'),
record.get('data_type'),
record.get('car_id'),
record.get('alias_name'),
record.get('manufacturer'),
created_at,
updated_at
))
self.executemany(insert_sql, params_list)
self.commit()
logger.info(f"MMI 코드 데이터 삽입 완료: {len(params_list)}")
def _load_signals(self, records: List[Dict[str, Any]]):
"""시그널 코드 데이터 삽입"""
if not records:
return
insert_sql = """
INSERT OR REPLACE INTO signals
(id, sig_num, signal_abbreviation, signal_description, status_value,
manufacturer, classification, original_data, alias_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
params_list = []
for record in records:
created_at = self._parse_datetime(record.get('created_at'))
updated_at = self._parse_datetime(record.get('updated_at'))
params_list.append((
record.get('id'),
record.get('sig_num'),
record.get('signal_abbreviation'),
record.get('signal_description'),
record.get('status_value'),
record.get('manufacturer'),
record.get('classification'),
record.get('original_data'),
record.get('alias_name'),
created_at,
updated_at
))
self.executemany(insert_sql, params_list)
self.commit()
logger.info(f"시그널 코드 데이터 삽입 완료: {len(params_list)}")
def _load_stations(self, records: List[Dict[str, Any]]):
"""역명 데이터 삽입"""
if not records:
return
insert_sql = """
INSERT OR REPLACE INTO stations
(id, line_number, station_id, station_name, station_map,
is_underground, is_island, is_exchange, is_end,
has_siding_track, has_signal_room, etc1, etc2, etc3, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
params_list = []
for record in records:
created_at = self._parse_datetime(record.get('created_at'))
updated_at = self._parse_datetime(record.get('updated_at'))
# 불리언 문자열을 정수로 변환
is_underground = 1 if str(record.get('is_underground', 'false')).lower() == 'true' else 0
is_island = 1 if str(record.get('is_island', 'false')).lower() == 'true' else 0
is_exchange = 1 if str(record.get('is_exchange', 'false')).lower() == 'true' else 0
is_end = 1 if str(record.get('is_end', 'false')).lower() == 'true' else 0
has_siding_track = 1 if str(record.get('has_siding_track', 'false')).lower() == 'true' else 0
has_signal_room = 1 if str(record.get('has_signal_room', 'false')).lower() == 'true' else 0
params_list.append((
record.get('id'),
record.get('line_number'),
record.get('station_id'),
record.get('station_name'),
record.get('station_map'),
is_underground,
is_island,
is_exchange,
is_end,
has_siding_track,
has_signal_room,
record.get('etc1'),
record.get('etc2'),
record.get('etc3'),
created_at,
updated_at
))
self.executemany(insert_sql, params_list)
self.commit()
logger.info(f"역명 데이터 삽입 완료: {len(params_list)}")
def _load_drawer_abbreviations(self, records: List[Dict[str, Any]]):
"""도면약어 데이터 삽입"""
if not records:
return
insert_sql = """
INSERT OR REPLACE INTO drawer_abbreviations
(id, abb, classification, related_drawings, drawing_id,
manufacturer, term, pages, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
params_list = []
for record in records:
created_at = self._parse_datetime(record.get('created_at'))
updated_at = self._parse_datetime(record.get('updated_at'))
params_list.append((
record.get('id'),
record.get('abb'),
record.get('classification'),
record.get('Related_drawings'), # 원본 컬럼명
record.get('drawing_id'),
record.get('manufacturer'),
record.get('term'),
record.get('pages'),
created_at,
updated_at
))
self.executemany(insert_sql, params_list)
self.commit()
logger.info(f"도면약어 데이터 삽입 완료: {len(params_list)}")
def _parse_datetime(self, dt_str: Optional[str]) -> Optional[str]:
"""datetime 문자열 파싱"""
if not dt_str:
return None
try:
# PostgreSQL 형식: '2025-10-12 15:03:22.31555+00'
# SQLite 형식으로 변환
if isinstance(dt_str, str):
# 타임존 제거
if '+' in dt_str:
dt_str = dt_str.split('+')[0]
# 밀리초 제거 (있는 경우)
if '.' in dt_str:
parts = dt_str.split('.')
dt_str = parts[0]
return dt_str
except Exception:
pass
return None

1963
database/crud.py Normal file

File diff suppressed because it is too large Load Diff

612
database/db_manager.py Normal file
View File

@ -0,0 +1,612 @@
# -*- coding: utf-8 -*-
"""
데이터베이스 연결 관리 모듈
SQLite 데이터베이스 연결 관리 기능을 제공합니다.
모듈은 다음 기능을 제공합니다:
- 데이터베이스 연결 관리
- 테이블 생성 마이그레이션
- 트랜잭션 관리
- 연결 관리
"""
import sqlite3
import threading
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple
from contextlib import contextmanager
from datetime import datetime
from core.constants import DB_FILE, DATA_DIR
from core.logger import get_logger
from core.exceptions import (
DatabaseConnectionError,
DatabaseQueryError,
)
# 로거 설정
logger = get_logger(__name__)
# ============================================================================
# SQL 스키마 정의
# ============================================================================
CREATE_TABLES_SQL = """
-- 사용자 테이블
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
department TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 테이블
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
shift_type TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 지시 섹션 테이블
CREATE TABLE IF NOT EXISTS instructions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_date DATE NOT NULL,
created_team TEXT NOT NULL,
instructor TEXT,
instruction_content TEXT NOT NULL,
instruction_date DATE,
is_continuous INTEGER DEFAULT 0,
team_confirmations TEXT DEFAULT '{}',
is_completed INTEGER DEFAULT 0,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
-- 고장 섹션 테이블
CREATE TABLE IF NOT EXISTS faults (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_date DATE NOT NULL,
created_team TEXT NOT NULL,
occurrence_date DATE,
train_number TEXT,
car_number TEXT,
fault_code TEXT,
device_category TEXT,
occurrence_station TEXT,
occurrence_time TIME,
fault_content TEXT,
action_content TEXT,
action_team TEXT,
team_confirmations TEXT DEFAULT '{}',
is_completed INTEGER DEFAULT 0,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
-- 작업 섹션 테이블
CREATE TABLE IF NOT EXISTS works (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_date DATE NOT NULL,
created_team TEXT NOT NULL,
work_date DATE,
work_entity TEXT,
target_train TEXT,
target_device TEXT,
work_content TEXT,
remarks TEXT,
team_confirmations TEXT DEFAULT '{}',
is_completed INTEGER DEFAULT 0,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
-- 기타 섹션 테이블
CREATE TABLE IF NOT EXISTS miscs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_date DATE NOT NULL,
created_team TEXT NOT NULL,
reporter TEXT,
report_content TEXT,
remarks TEXT,
related_document TEXT,
team_confirmations TEXT DEFAULT '{}',
is_completed INTEGER DEFAULT 0,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
-- 일상검수 테이블
CREATE TABLE IF NOT EXISTS daily_inspections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inspection_date DATE NOT NULL,
shift_type TEXT NOT NULL,
slot_number INTEGER NOT NULL,
train_number TEXT,
cleaning_type TEXT DEFAULT '없음',
has_work INTEGER DEFAULT 0,
work_content TEXT,
is_work_completed INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(inspection_date, shift_type, slot_number)
);
-- 할일 테이블
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
todo_date DATE NOT NULL,
category TEXT DEFAULT '일반',
target_train TEXT,
schedule TEXT,
content TEXT NOT NULL,
is_completed INTEGER DEFAULT 0,
completed_at TIMESTAMP,
alarm_time TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
-- 메모 테이블
CREATE TABLE IF NOT EXISTS memos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memo_date DATE NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
-- 설정 테이블
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 인원 테이블
CREATE TABLE IF NOT EXISTS team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team TEXT NOT NULL,
position TEXT NOT NULL,
name TEXT NOT NULL,
"order" INTEGER DEFAULT 0,
partner_id INTEGER REFERENCES team_members(id),
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 당무 일정 테이블
CREATE TABLE IF NOT EXISTS duty_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
duty_date DATE NOT NULL,
team TEXT NOT NULL,
shift_type TEXT NOT NULL,
vice_leader_id INTEGER REFERENCES team_members(id),
operator_id INTEGER REFERENCES team_members(id),
vice_leader_name TEXT,
operator_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(duty_date, team, shift_type)
);
-- 조치 단계 테이블
CREATE TABLE IF NOT EXISTS action_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fault_id INTEGER NOT NULL REFERENCES faults(id) ON DELETE CASCADE,
step_number INTEGER NOT NULL,
action_content TEXT NOT NULL,
action_team TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(fault_id, step_number)
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_instructions_created_date ON instructions(created_date);
CREATE INDEX IF NOT EXISTS idx_instructions_is_completed ON instructions(is_completed);
CREATE INDEX IF NOT EXISTS idx_faults_created_date ON faults(created_date);
CREATE INDEX IF NOT EXISTS idx_faults_train_number ON faults(train_number);
CREATE INDEX IF NOT EXISTS idx_faults_occurrence_date ON faults(occurrence_date);
CREATE INDEX IF NOT EXISTS idx_works_work_date ON works(work_date);
CREATE INDEX IF NOT EXISTS idx_works_target_train ON works(target_train);
CREATE INDEX IF NOT EXISTS idx_daily_inspections_date ON daily_inspections(inspection_date);
CREATE INDEX IF NOT EXISTS idx_todos_date ON todos(todo_date);
CREATE INDEX IF NOT EXISTS idx_todos_is_completed ON todos(is_completed);
CREATE INDEX IF NOT EXISTS idx_memos_date ON memos(memo_date);
CREATE INDEX IF NOT EXISTS idx_action_steps_fault_id ON action_steps(fault_id);
CREATE INDEX IF NOT EXISTS idx_action_steps_step_number ON action_steps(fault_id, step_number);
"""
# 기본 데이터 삽입 SQL
INSERT_DEFAULT_DATA_SQL = """
-- 기본 데이터
INSERT OR IGNORE INTO teams (name, shift_type, is_active) VALUES ('1팀', '주간', 1);
INSERT OR IGNORE INTO teams (name, shift_type, is_active) VALUES ('2팀', '야간', 1);
INSERT OR IGNORE INTO teams (name, shift_type, is_active) VALUES ('3팀', '주간', 1);
INSERT OR IGNORE INTO teams (name, shift_type, is_active) VALUES ('4팀', '야간', 1);
-- 기본 관리자 계정 (비밀번호: admin123)
INSERT OR IGNORE INTO users (username, password_hash, name, department, role, is_active)
VALUES ('admin', 'pbkdf2:sha256:260000$salt$hash', '관리자', '검수팀', 'admin', 1);
"""
# ============================================================================
# 데이터베이스 관리자 클래스
# ============================================================================
class DatabaseManager:
"""
데이터베이스 관리자 클래스
싱글톤 패턴을 사용하여 애플리케이션 전역에서 하나의 인스턴스만 사용합니다.
SQLite 데이터베이스 연결 기본 작업을 관리합니다.
Attributes:
db_path: 데이터베이스 파일 경로
connection: 현재 데이터베이스 연결
Examples:
>>> db = DatabaseManager()
>>> with db.get_connection() as conn:
... cursor = conn.execute("SELECT * FROM users")
... users = cursor.fetchall()
"""
_instance: Optional['DatabaseManager'] = None
_lock = threading.Lock()
def __new__(cls, db_path: Path = None):
"""싱글톤 패턴 구현"""
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, db_path: Path = None):
"""
데이터베이스 관리자 초기화
Args:
db_path: 데이터베이스 파일 경로 (기본값: DB_FILE)
"""
# 이미 초기화된 경우 건너뛰기
if self._initialized:
return
self.db_path = db_path or DB_FILE
self._local = threading.local()
# 데이터 디렉토리 생성
DATA_DIR.mkdir(parents=True, exist_ok=True)
# 데이터베이스 초기화
self._initialize_database()
self._initialized = True
logger.info(f"데이터베이스 관리자 초기화 완료: {self.db_path}")
def _initialize_database(self):
"""데이터베이스 초기화 (테이블 생성 및 마이그레이션)"""
try:
with self.get_connection() as conn:
# 외래 키 활성화
conn.execute("PRAGMA foreign_keys = ON")
# 테이블 생성
conn.executescript(CREATE_TABLES_SQL)
# 기본 데이터 삽입
conn.executescript(INSERT_DEFAULT_DATA_SQL)
# 마이그레이션: daily_inspections 테이블 컬럼 추가
try:
conn.execute("ALTER TABLE daily_inspections ADD COLUMN work_content TEXT")
conn.execute("ALTER TABLE daily_inspections ADD COLUMN is_work_completed INTEGER DEFAULT 0")
except sqlite3.OperationalError:
pass
# todos 테이블에 alarm_time 컬럼 추가
try:
conn.execute("ALTER TABLE todos ADD COLUMN alarm_time TIMESTAMP")
except sqlite3.OperationalError:
pass
# todos 테이블에 category 컬럼 추가
try:
conn.execute("ALTER TABLE todos ADD COLUMN category TEXT DEFAULT '일반'")
except sqlite3.OperationalError:
pass
conn.commit()
logger.info("데이터베이스 테이블 초기화 완료")
except Exception as e:
logger.error(f"데이터베이스 초기화 실패: {e}")
raise DatabaseConnectionError(f"데이터베이스 초기화 실패: {e}")
@contextmanager
def get_connection(self):
"""
데이터베이스 연결을 반환하는 컨텍스트 매니저
스레드별로 별도의 연결을 유지합니다.
Yields:
sqlite3.Connection: 데이터베이스 연결
Examples:
>>> with db.get_connection() as conn:
... cursor = conn.execute("SELECT * FROM users")
"""
try:
# 스레드별 연결 가져오기 또는 생성
if not hasattr(self._local, 'connection') or self._local.connection is None:
self._local.connection = sqlite3.connect(
self.db_path,
detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
)
# Row 팩토리 설정 (딕셔너리처럼 접근 가능)
self._local.connection.row_factory = sqlite3.Row
# 외래 키 활성화
self._local.connection.execute("PRAGMA foreign_keys = ON")
yield self._local.connection
except sqlite3.Error as e:
logger.error(f"데이터베이스 연결 오류: {e}")
raise DatabaseConnectionError(f"데이터베이스 연결 실패: {e}")
def execute(
self,
query: str,
params: Tuple = None,
commit: bool = True
) -> sqlite3.Cursor:
"""
SQL 쿼리를 실행합니다.
Args:
query: SQL 쿼리
params: 쿼리 파라미터
commit: 자동 커밋 여부
Returns:
실행 결과 커서
Examples:
>>> cursor = db.execute(
... "INSERT INTO users (name) VALUES (?)",
... ("홍길동",)
... )
>>> print(cursor.lastrowid)
"""
try:
with self.get_connection() as conn:
if params:
cursor = conn.execute(query, params)
else:
cursor = conn.execute(query)
if commit:
conn.commit()
return cursor
except sqlite3.Error as e:
logger.error(f"쿼리 실행 오류: {query[:100]}... - {e}")
raise DatabaseQueryError(f"쿼리 실행 실패: {e}", query)
def execute_many(
self,
query: str,
params_list: List[Tuple],
commit: bool = True
) -> sqlite3.Cursor:
"""
여러 SQL 쿼리를 일괄 실행합니다.
Args:
query: SQL 쿼리
params_list: 파라미터 리스트
commit: 자동 커밋 여부
Returns:
실행 결과 커서
"""
try:
with self.get_connection() as conn:
cursor = conn.executemany(query, params_list)
if commit:
conn.commit()
return cursor
except sqlite3.Error as e:
logger.error(f"일괄 쿼리 실행 오류: {e}")
raise DatabaseQueryError(f"일괄 쿼리 실행 실패: {e}", query)
def fetch_one(
self,
query: str,
params: Tuple = None
) -> Optional[Dict[str, Any]]:
"""
단일 레코드를 조회합니다.
Args:
query: SQL 쿼리
params: 쿼리 파라미터
Returns:
레코드 딕셔너리 또는 None
Examples:
>>> user = db.fetch_one(
... "SELECT * FROM users WHERE id = ?",
... (1,)
... )
"""
try:
with self.get_connection() as conn:
if params:
cursor = conn.execute(query, params)
else:
cursor = conn.execute(query)
row = cursor.fetchone()
return dict(row) if row else None
except sqlite3.Error as e:
logger.error(f"단일 조회 오류: {e}")
raise DatabaseQueryError(f"조회 실패: {e}", query)
def fetch_all(
self,
query: str,
params: Tuple = None
) -> List[Dict[str, Any]]:
"""
여러 레코드를 조회합니다.
Args:
query: SQL 쿼리
params: 쿼리 파라미터
Returns:
레코드 딕셔너리 리스트
Examples:
>>> users = db.fetch_all("SELECT * FROM users WHERE is_active = 1")
"""
try:
with self.get_connection() as conn:
if params:
cursor = conn.execute(query, params)
else:
cursor = conn.execute(query)
rows = cursor.fetchall()
return [dict(row) for row in rows]
except sqlite3.Error as e:
logger.error(f"다중 조회 오류: {e}")
raise DatabaseQueryError(f"조회 실패: {e}", query)
def table_exists(self, table_name: str) -> bool:
"""
테이블 존재 여부를 확인합니다.
Args:
table_name: 테이블 이름
Returns:
테이블 존재 여부
"""
query = """
SELECT name FROM sqlite_master
WHERE type='table' AND name=?
"""
result = self.fetch_one(query, (table_name,))
return result is not None
def get_table_columns(self, table_name: str) -> List[str]:
"""
테이블의 컬럼 목록을 반환합니다.
Args:
table_name: 테이블 이름
Returns:
컬럼 이름 리스트
"""
query = f"PRAGMA table_info({table_name})"
rows = self.fetch_all(query)
return [row['name'] for row in rows]
def backup(self, backup_path: Path = None) -> bool:
"""
데이터베이스를 백업합니다.
Args:
backup_path: 백업 파일 경로
Returns:
백업 성공 여부
"""
try:
if backup_path is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = DATA_DIR / f"backup_{timestamp}.db"
with self.get_connection() as conn:
backup_conn = sqlite3.connect(backup_path)
conn.backup(backup_conn)
backup_conn.close()
logger.info(f"데이터베이스 백업 완료: {backup_path}")
return True
except Exception as e:
logger.error(f"데이터베이스 백업 실패: {e}")
return False
def vacuum(self):
"""데이터베이스 최적화 (VACUUM)"""
try:
with self.get_connection() as conn:
conn.execute("VACUUM")
logger.info("데이터베이스 VACUUM 완료")
except Exception as e:
logger.error(f"데이터베이스 VACUUM 실패: {e}")
def close(self):
"""현재 스레드의 연결을 닫습니다."""
if hasattr(self._local, 'connection') and self._local.connection:
self._local.connection.close()
self._local.connection = None
logger.debug("데이터베이스 연결 종료")
def close_all(self):
"""모든 연결을 닫습니다."""
self.close()
DatabaseManager._instance = None
logger.info("모든 데이터베이스 연결 종료")
# ============================================================================
# 모듈 레벨 편의 함수
# ============================================================================
def get_db() -> DatabaseManager:
"""
데이터베이스 관리자 인스턴스를 반환합니다.
Returns:
DatabaseManager 인스턴스
"""
return DatabaseManager()

278
database/migrations.py Normal file
View File

@ -0,0 +1,278 @@
# -*- coding: utf-8 -*-
"""
데이터베이스 마이그레이션 모듈
데이터베이스 스키마 변경을 관리합니다.
모듈은 다음 기능을 제공합니다:
- 마이그레이션 버전 관리
- 스키마 업그레이드/다운그레이드
- 마이그레이션 이력 추적
"""
from datetime import datetime
from typing import List, Callable, Optional
from dataclasses import dataclass
from .db_manager import DatabaseManager, get_db
from core.logger import get_logger
# 로거 설정
logger = get_logger(__name__)
@dataclass
class Migration:
"""
마이그레이션 정의 클래스
Attributes:
version: 마이그레이션 버전
description: 마이그레이션 설명
upgrade: 업그레이드 SQL 또는 함수
downgrade: 다운그레이드 SQL 또는 함수
"""
version: int
description: str
upgrade: str
downgrade: str = ""
class MigrationManager:
"""
마이그레이션 관리자 클래스
데이터베이스 스키마 마이그레이션을 관리합니다.
Attributes:
db: 데이터베이스 관리자
migrations: 마이그레이션 목록
"""
def __init__(self):
"""마이그레이션 관리자 초기화"""
self.db = get_db()
self.migrations: List[Migration] = []
# 마이그레이션 테이블 생성
self._create_migration_table()
# 마이그레이션 정의
self._define_migrations()
def _create_migration_table(self):
"""마이그레이션 이력 테이블 생성"""
query = """
CREATE TABLE IF NOT EXISTS _migrations (
version INTEGER PRIMARY KEY,
description TEXT,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""
self.db.execute(query)
def _define_migrations(self):
"""마이그레이션 정의"""
# 마이그레이션 1: 초기 스키마 (db_manager에서 이미 생성)
self.migrations.append(Migration(
version=1,
description="Initial schema",
upgrade="-- Initial schema created in db_manager",
downgrade=""
))
# 마이그레이션 2: 고장 테이블에 심각도 필드 추가 (예시)
self.migrations.append(Migration(
version=2,
description="Add severity field to faults table",
upgrade="""
ALTER TABLE faults ADD COLUMN severity TEXT DEFAULT 'normal';
""",
downgrade="""
-- SQLite doesn't support DROP COLUMN directly
-- This would require table recreation
"""
))
# 마이그레이션 3: 사용자 테이블에 마지막 로그인 필드 추가 (예시)
self.migrations.append(Migration(
version=3,
description="Add last_login field to users table",
upgrade="""
ALTER TABLE users ADD COLUMN last_login DATETIME;
""",
downgrade=""
))
# 마이그레이션 4: todos 테이블에 category 필드 추가
self.migrations.append(Migration(
version=4,
description="Add category field to todos table",
upgrade="""
ALTER TABLE todos ADD COLUMN category TEXT DEFAULT '일반';
""",
downgrade=""
))
# 마이그레이션 5: faults 테이블에 column_number 필드 추가
self.migrations.append(Migration(
version=5,
description="Add column_number field to faults table",
upgrade="""
ALTER TABLE faults ADD COLUMN column_number TEXT;
""",
downgrade=""
))
# 마이그레이션 6: faults 테이블에 fault_source 필드 추가
self.migrations.append(Migration(
version=6,
description="Add fault_source field to faults table",
upgrade="""
ALTER TABLE faults ADD COLUMN fault_source TEXT;
""",
downgrade=""
))
# 마이그레이션 7: weather 테이블 생성
self.migrations.append(Migration(
version=7,
description="Create weather table",
upgrade="""
CREATE TABLE weather (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datetime TEXT NOT NULL,
location_name TEXT NOT NULL,
location_code TEXT NOT NULL,
temp INTEGER,
feels_like INTEGER,
humidity INTEGER,
wind_speed TEXT,
wind_direction TEXT,
precipitation_prob INTEGER,
weather_condition TEXT,
weather_icon TEXT,
created_at TEXT,
updated_at TEXT,
UNIQUE(datetime, location_code)
);
CREATE INDEX idx_weather_datetime_location ON weather(datetime, location_code);
CREATE INDEX idx_weather_location_code ON weather(location_code);
""",
downgrade=""
))
def get_current_version(self) -> int:
"""
현재 마이그레이션 버전을 반환합니다.
Returns:
현재 버전 (마이그레이션이 없으면 0)
"""
query = "SELECT MAX(version) as version FROM _migrations"
result = self.db.fetch_one(query)
return result['version'] if result and result['version'] else 0
def get_pending_migrations(self) -> List[Migration]:
"""
적용되지 않은 마이그레이션 목록을 반환합니다.
Returns:
대기 중인 마이그레이션 목록
"""
current = self.get_current_version()
return [m for m in self.migrations if m.version > current]
def apply_migration(self, migration: Migration) -> bool:
"""
단일 마이그레이션을 적용합니다.
Args:
migration: 적용할 마이그레이션
Returns:
적용 성공 여부
"""
try:
# 업그레이드 SQL 실행
if migration.upgrade.strip():
with self.db.get_connection() as conn:
conn.executescript(migration.upgrade)
conn.commit()
# 마이그레이션 이력 기록
query = """
INSERT INTO _migrations (version, description, applied_at)
VALUES (?, ?, ?)
"""
self.db.execute(
query,
(migration.version, migration.description, datetime.now().isoformat())
)
logger.info(f"마이그레이션 적용: v{migration.version} - {migration.description}")
return True
except Exception as e:
logger.error(f"마이그레이션 실패: v{migration.version} - {e}")
return False
def migrate(self, target_version: int = None) -> bool:
"""
마이그레이션을 실행합니다.
Args:
target_version: 목표 버전 (None이면 최신 버전)
Returns:
마이그레이션 성공 여부
"""
if target_version is None:
target_version = max(m.version for m in self.migrations) if self.migrations else 0
current = self.get_current_version()
if current >= target_version:
logger.info(f"마이그레이션 불필요: 현재 v{current}")
return True
# 적용할 마이그레이션 필터링
to_apply = [m for m in self.migrations
if current < m.version <= target_version]
to_apply.sort(key=lambda m: m.version)
for migration in to_apply:
if not self.apply_migration(migration):
return False
logger.info(f"마이그레이션 완료: v{current} -> v{target_version}")
return True
def rollback(self, target_version: int) -> bool:
"""
마이그레이션을 롤백합니다.
Args:
target_version: 목표 버전
Returns:
롤백 성공 여부
Note:
SQLite의 제한으로 인해 실제 롤백은 제한적입니다.
"""
current = self.get_current_version()
if current <= target_version:
logger.info(f"롤백 불필요: 현재 v{current}")
return True
logger.warning("SQLite 롤백은 제한적입니다. 백업에서 복원을 권장합니다.")
return False
def run_migrations():
"""마이그레이션을 실행합니다."""
manager = MigrationManager()
return manager.migrate()

634
database/models.py Normal file
View File

@ -0,0 +1,634 @@
# -*- coding: utf-8 -*-
"""
데이터 모델 정의 모듈
데이터베이스 테이블에 대응하는 데이터 모델 클래스들을 정의합니다.
모델 클래스는 테이블 스키마를 반영하며,
데이터 유효성 검사 직렬화 기능을 제공합니다.
"""
import json
from dataclasses import dataclass, field, asdict
from datetime import datetime, date, time
from typing import Optional, Dict, Any, List
from enum import Enum
# ============================================================================
# 열거형 정의
# ============================================================================
class Role(Enum):
"""사용자 역할"""
ADMIN = "admin"
EDITOR = "editor"
VIEWER = "viewer"
class ShiftType(Enum):
"""근무 유형"""
DAY = "주간"
NIGHT = "야간"
class CleaningType(Enum):
"""청소 유형"""
NONE = "없음"
MEDIUM = "중청소"
LARGE = "대청소"
# ============================================================================
# 기본 모델 클래스
# ============================================================================
@dataclass
class BaseModel:
"""
기본 모델 클래스
모든 데이터 모델의 기반 클래스입니다.
공통 필드 유틸리티 메서드를 제공합니다.
"""
id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""
모델을 딕셔너리로 변환합니다.
Returns:
모델 데이터 딕셔너리
"""
data = asdict(self)
# datetime 객체를 문자열로 변환
for key, value in data.items():
if isinstance(value, datetime):
data[key] = value.isoformat()
elif isinstance(value, date):
data[key] = value.isoformat()
elif isinstance(value, time):
data[key] = value.isoformat()
elif isinstance(value, Enum):
data[key] = value.value
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'BaseModel':
"""
딕셔너리에서 모델을 생성합니다.
Args:
data: 모델 데이터 딕셔너리
Returns:
모델 인스턴스
"""
# datetime 문자열을 객체로 변환
for key in ['created_at', 'updated_at']:
if key in data and isinstance(data[key], str):
try:
data[key] = datetime.fromisoformat(data[key])
except ValueError:
data[key] = None
return cls(**data)
# ============================================================================
# 사용자 모델
# ============================================================================
@dataclass
class User(BaseModel):
"""
사용자 모델
Attributes:
username: 사용자 ID (고유)
password_hash: 비밀번호 해시
name: 이름
department: 부서
role: 역할 (admin, editor, viewer)
is_active: 활성화 여부
"""
username: str = ""
password_hash: str = ""
name: str = ""
department: str = ""
role: str = "viewer"
is_active: bool = True
def has_permission(self, action: str) -> bool:
"""
특정 동작에 대한 권한이 있는지 확인합니다.
Args:
action: 동작 (create, read, update, delete)
Returns:
권한 여부
"""
if self.role == Role.ADMIN.value:
return True
elif self.role == Role.EDITOR.value:
return action in ['create', 'read', 'update']
else: # viewer
return action == 'read'
def is_admin(self) -> bool:
"""관리자 여부 확인"""
return self.role == Role.ADMIN.value or self.department == "검수팀"
# ============================================================================
# 팀 모델
# ============================================================================
@dataclass
class Team(BaseModel):
"""
모델
Attributes:
name: 이름 (A팀, B팀, C팀, D팀)
shift_type: 근무 유형 (주간, 야간)
is_active: 활성화 여부
"""
name: str = ""
shift_type: str = ""
is_active: bool = True
# ============================================================================
# 섹션 공통 모델
# ============================================================================
@dataclass
class SectionBase(BaseModel):
"""
섹션 기본 모델
모든 섹션(지시, 고장, 작업, 기타) 공통 필드를 정의합니다.
"""
created_date: Optional[date] = None
created_team: str = ""
team_confirmations: str = "{}" # JSON 문자열
is_completed: bool = False
completed_at: Optional[datetime] = None
created_by: Optional[int] = None
def get_team_confirmations(self) -> Dict[str, bool]:
"""팀 확인 상태를 딕셔너리로 반환"""
try:
return json.loads(self.team_confirmations)
except json.JSONDecodeError:
return {"1팀": False, "2팀": False, "3팀": False, "4팀": False}
def set_team_confirmation(self, team: str, confirmed: bool):
"""특정 팀의 확인 상태를 설정"""
confirmations = self.get_team_confirmations()
confirmations[team] = confirmed
self.team_confirmations = json.dumps(confirmations, ensure_ascii=False)
# 모든 팀이 확인했는지 체크
if all(confirmations.values()):
self.is_completed = True
self.completed_at = datetime.now()
def all_teams_confirmed(self) -> bool:
"""모든 팀이 확인했는지 반환"""
confirmations = self.get_team_confirmations()
return all(confirmations.values())
# ============================================================================
# 지시 섹션 모델
# ============================================================================
@dataclass
class Instruction(SectionBase):
"""
지시 섹션 모델
상위부서나 상급자의 지시사항을 기록합니다.
Attributes:
instructor: 지시자
instruction_content: 지시내용
instruction_date: 지시일자
is_continuous: 지속여부
"""
instructor: str = ""
instruction_content: str = ""
instruction_date: Optional[date] = None
is_continuous: bool = False
# ============================================================================
# 고장 섹션 모델
# ============================================================================
@dataclass
class Fault(SectionBase):
"""
고장 섹션 모델
전동차 고장 정보를 기록합니다.
Attributes:
occurrence_date: 발생일자
column_number: 열번
train_number: 편성번호
car_number: 호차
fault_code: 고장코드
device_category: 장치분류
occurrence_station: 발생역
occurrence_time: 발생시간
fault_content: 고장내용
action_content: 조치내용
action_team: 조치팀
fault_source: 고장출처
severity: 심각도 (normal, high, critical)
"""
occurrence_date: Optional[date] = None
column_number: str = ""
train_number: str = ""
car_number: str = ""
fault_code: str = ""
device_category: str = ""
occurrence_station: str = ""
occurrence_time: Optional[time] = None
fault_content: str = ""
action_content: str = ""
action_team: str = ""
fault_source: str = "" # 고장출처
severity: str = "normal" # 심각도 (normal, high, critical)
# ============================================================================
# 작업 섹션 모델
# ============================================================================
@dataclass
class Work(SectionBase):
"""
작업 섹션 모델
전동차 관련 작업일정을 기록합니다.
Attributes:
work_date: 작업일정
work_entity: 작업주체
target_train: 대상편성
target_device: 대상기기
work_content: 작업내용
remarks: 특이사항
"""
work_date: Optional[date] = None
work_entity: str = ""
target_train: str = ""
target_device: str = ""
work_content: str = ""
remarks: str = ""
# ============================================================================
# 기타 섹션 모델
# ============================================================================
@dataclass
class Misc(SectionBase):
"""
기타 섹션 모델
전동차 관련 작업 나머지 사항을 기록합니다.
Attributes:
reporter: 전달자
report_content: 전달내용
remarks: 특이사항
related_document: 관련문서
"""
reporter: str = ""
report_content: str = ""
remarks: str = ""
related_document: str = ""
# ============================================================================
# 일상검수 모델
# ============================================================================
@dataclass
class DailyInspection(BaseModel):
"""
일상검수 모델
일일 점검 대상 편성을 기록합니다.
Attributes:
inspection_date: 검수일자
shift_type: 근무유형 (주간, 야간)
slot_number: 슬롯번호 (1~5)
train_number: 편성번호
cleaning_type: 청소유형 (없음, 중청소, 대청소)
has_work: 작업여부
"""
inspection_date: Optional[date] = None
shift_type: str = ""
slot_number: int = 0
train_number: str = ""
cleaning_type: str = "없음"
has_work: bool = False
created_by: Optional[int] = None
# ============================================================================
# Todo 모델
# ============================================================================
class TodoCategory:
"""할일 카테고리"""
GENERAL = "일반" # 일반 할일
ARRIVAL_INSPECTION = "도착검수" # 도착검수
TASK = "작업" # 작업
@dataclass
class Todo(BaseModel):
"""
할일 모델
할일 목록을 기록합니다.
Attributes:
todo_date: 할일 날짜
category: 카테고리 (일반, 도착검수, 작업)
target_train: 대상편성
schedule: 일정
content: 내용
is_completed: 완료여부
completed_at: 완료시간
"""
todo_date: Optional[date] = None
category: str = "일반"
target_train: str = ""
schedule: str = ""
content: str = ""
is_completed: bool = False
completed_at: Optional[datetime] = None
alarm_time: Optional[datetime] = None
created_by: Optional[int] = None
# ============================================================================
# 메모 모델
# ============================================================================
@dataclass
class Memo(BaseModel):
"""
메모 모델
메모를 기록합니다.
Attributes:
memo_date: 메모 날짜
content: 내용
"""
memo_date: Optional[date] = None
content: str = ""
created_by: Optional[int] = None
# ============================================================================
# 설정 모델
# ============================================================================
@dataclass
class Setting(BaseModel):
"""
설정 모델
- 형태의 설정을 저장합니다.
Attributes:
key: 설정
value: 설정
"""
key: str = ""
value: str = ""
# ============================================================================
# 팀 인원 모델
# ============================================================================
@dataclass
class TeamMember(BaseModel):
"""
인원 모델
팀의 구성원 정보를 저장합니다.
Attributes:
team: (1, 2, 3, 4)
position: 직책 (부팀장, 운용)
name: 이름
order: 순서 (당무 순서)
partner_id: 짝궁 ID (함께 당무 서는 사람)
is_active: 활성화 여부
"""
team: str = ""
position: str = ""
name: str = ""
order: int = 0
partner_id: Optional[int] = None
is_active: bool = True
@dataclass
class DutySchedule(BaseModel):
"""
당무 일정 모델
일별 당무자 정보를 저장합니다.
Attributes:
duty_date: 당무 날짜
team:
shift_type: 근무 유형 (주간, 야간)
vice_leader_id: 당무 부팀장 ID
operator_id: 당무 운용 ID
vice_leader_name: 당무 부팀장 이름 (조회용)
operator_name: 당무 운용 이름 (조회용)
"""
duty_date: Optional[date] = None
team: str = ""
shift_type: str = ""
vice_leader_id: Optional[int] = None
operator_id: Optional[int] = None
vice_leader_name: str = ""
operator_name: str = ""
# ============================================================================
# 날씨 모델
# ============================================================================
@dataclass
class Weather(BaseModel):
"""
날씨 모델
시간별 날씨 정보를 저장합니다.
Attributes:
datetime: 날씨 데이터 시각
location_name: 지역명
location_code: 지역코드
temp: 기온
feels_like: 체감온도
humidity: 습도
wind_speed: 풍속
wind_direction: 풍향
precipitation_prob: 강수확률
weather_condition: 날씨 상태
weather_icon: 날씨 아이콘
"""
datetime: Optional[datetime] = None
location_name: str = ""
location_code: str = ""
temp: Optional[int] = None
feels_like: Optional[int] = None
humidity: Optional[int] = None
wind_speed: str = ""
wind_direction: str = ""
precipitation_prob: Optional[int] = None
weather_condition: str = ""
weather_icon: str = ""
# ============================================================================
# 열차 다이아 시각표 모델
# ============================================================================
@dataclass
class TrainSchedule(BaseModel):
"""
열차 다이아 시각표 모델
열번과 역별 도착/출발 시각을 저장합니다.
열번과 역명으로 발생 시간을 유추할 사용됩니다.
Attributes:
column_number: 열번 (: "1001", "1002")
station: 역명 (: "신평역", "하단역")
arrival_time: 도착 시간
departure_time: 출발 시간
direction: 방향 (up: 상행, down: 하행)
is_weekday: 평일 여부 (True: 평일, False: 주말/휴일)
is_active: 활성화 여부
"""
column_number: str = ""
station: str = ""
arrival_time: Optional[time] = None
departure_time: Optional[time] = None
direction: str = "up" # up: 상행, down: 하행
is_weekday: bool = True
is_active: bool = True
# ============================================================================
# 전동차 편성 모델
# ============================================================================
@dataclass
class TrainFormation(BaseModel):
"""
전동차 편성 모델
편성번호별 전동차 정보를 관리합니다.
Attributes:
train_number: 편성번호 (: 134a, 134b, 1A)
is_new_train: 신차 여부 (True: 신차, False: 구차)
manufacturer: 제조사
introduction_date: 도입일
depot: 배속지 (신평, 노포)
alias: 별칭
introduction_stage: 도입단계
introduction_count: 도입량
"""
train_number: str = ""
is_new_train: bool = True
manufacturer: str = ""
introduction_date: Optional[date] = None
depot: str = ""
alias: str = ""
introduction_stage: str = ""
introduction_count: int = 0
# ============================================================================
# 조치 단계 모델
# ============================================================================
@dataclass
class ActionStep(BaseModel):
"""
조치 단계 모델
고장에 대한 조치를 단계별로 기록합니다.
Attributes:
fault_id: 고장 ID (외래키)
step_number: 단계 번호
action_content: 조치 내용
action_team: 조치팀
"""
fault_id: int = 0
step_number: int = 0
action_content: str = ""
action_team: str = ""
# ============================================================================
# 모델 레지스트리
# ============================================================================
# 테이블 이름과 모델 클래스 매핑
MODEL_REGISTRY: Dict[str, type] = {
"users": User,
"teams": Team,
"instructions": Instruction,
"faults": Fault,
"works": Work,
"miscs": Misc,
"daily_inspections": DailyInspection,
"todos": Todo,
"memos": Memo,
"settings": Setting,
"team_members": TeamMember,
"duty_schedules": DutySchedule,
"train_schedules": TrainSchedule,
"weather": Weather,
"train_formations": TrainFormation,
"action_steps": ActionStep,
}
def get_model_class(table_name: str) -> Optional[type]:
"""
테이블 이름에 해당하는 모델 클래스를 반환합니다.
Args:
table_name: 테이블 이름
Returns:
모델 클래스
"""
return MODEL_REGISTRY.get(table_name)

198
database/sql_loader.py Normal file
View File

@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
"""
SQL 파일 로더 모듈
PostgreSQL 형식의 INSERT 문을 파싱하여 SQLite에 삽입합니다.
"""
import re
import ast
from pathlib import Path
from typing import List, Dict, Any, Optional
from datetime import datetime
from core.logger import get_logger
logger = get_logger(__name__)
def parse_postgresql_insert(sql_content: str) -> List[Dict[str, Any]]:
"""
PostgreSQL 형식의 INSERT 문을 파싱합니다.
형식: INSERT INTO "table" ("col1", "col2") VALUES ('val1', 'val2'), ('val3', 'val4')
Args:
sql_content: SQL 파일 내용
Returns:
파싱된 레코드 리스트
"""
records = []
# INSERT 문 패턴 매칭
pattern = r'INSERT INTO\s+"[^"]+"\s*\(([^)]+)\)\s*VALUES\s*(.+?)(?=\s*;|\s*$)'
matches = re.finditer(pattern, sql_content, re.IGNORECASE | re.DOTALL)
for match in matches:
columns_str = match.group(1)
values_str = match.group(2)
# 컬럼명 파싱
columns = [col.strip().strip('"') for col in columns_str.split(',')]
# VALUES 부분 파싱 (여러 레코드가 있을 수 있음)
# VALUES ('val1', 'val2'), ('val3', 'val4') 형식
value_records = _parse_values(values_str)
for value_record in value_records:
if len(value_record) == len(columns):
record = dict(zip(columns, value_record))
records.append(record)
else:
logger.warning(f"컬럼 수 불일치: {len(columns)} 컬럼, {len(value_record)}")
return records
def _parse_values(values_str: str) -> List[List[Any]]:
"""
VALUES 부분을 파싱합니다.
Args:
values_str: VALUES ('val1', 'val2'), ('val3', 'val4') 형식의 문자열
Returns:
리스트의 리스트
"""
records = []
# 괄호로 묶인 각 레코드 찾기
# 복잡한 경우를 처리하기 위해 스택 사용
current_record = []
current_value = ""
in_quotes = False
quote_char = None
paren_depth = 0
i = 0
while i < len(values_str):
char = values_str[i]
# 이스케이프 처리
if char == '\\' and i + 1 < len(values_str):
current_value += char + values_str[i + 1]
i += 2
continue
# 따옴표 처리
if char in ("'", '"'):
if not in_quotes:
in_quotes = True
quote_char = char
current_value += char
elif char == quote_char:
# 닫는 따옴표
in_quotes = False
quote_char = None
current_value += char
else:
current_value += char
# 괄호 처리
elif char == '(' and not in_quotes:
paren_depth += 1
if paren_depth == 1:
# 새로운 레코드 시작
current_record = []
current_value = ""
else:
current_value += char
elif char == ')' and not in_quotes:
paren_depth -= 1
if paren_depth == 0:
# 레코드 완료
if current_value.strip():
current_record.append(_clean_value(current_value.strip()))
records.append(current_record)
current_record = []
current_value = ""
else:
current_value += char
elif char == ',' and not in_quotes and paren_depth == 1:
# 레코드 내 값 구분자
if current_value.strip():
current_record.append(_clean_value(current_value.strip()))
current_value = ""
else:
current_value += char
i += 1
return records
def _clean_value(value: str) -> Any:
"""
값을 정리합니다.
Args:
value: 원시 문자열
Returns:
정리된 (None, 문자열, 숫자 )
"""
value = value.strip()
# NULL 처리
if value.upper() == 'NULL' or value == '':
return None
# 문자열 처리 (따옴표 제거)
if value.startswith("'") and value.endswith("'"):
# 이스케이프 처리
result = value[1:-1].replace("''", "'").replace("\\'", "'")
return result
elif value.startswith('"') and value.endswith('"'):
result = value[1:-1].replace('""', '"').replace('\\"', '"')
return result
# 숫자 처리
try:
if '.' in value:
return float(value)
else:
return int(value)
except ValueError:
pass
# 불리언 처리
if value.lower() == 'true':
return True
elif value.lower() == 'false':
return False
return value
def load_sql_file(sql_file: Path) -> List[Dict[str, Any]]:
"""
SQL 파일을 로드하고 파싱합니다.
Args:
sql_file: SQL 파일 경로
Returns:
파싱된 레코드 리스트
"""
try:
with open(sql_file, 'r', encoding='utf-8') as f:
content = f.read()
records = parse_postgresql_insert(content)
logger.info(f"SQL 파일 파싱 완료: {sql_file.name} ({len(records)}개 레코드)")
return records
except Exception as e:
logger.error(f"SQL 파일 로드 실패 ({sql_file}): {e}")
return []

288
database/sync_manager.py Normal file
View File

@ -0,0 +1,288 @@
# -*- coding: utf-8 -*-
"""
원격 데이터베이스 동기화 모듈
로컬 SQLite와 원격 Supabase 간의 데이터 동기화를 관리합니다.
현재는 프로토타입으로 인터페이스만 정의되어 있으며,
추후 Supabase 연동 구현될 예정입니다.
"""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
from core.logger import get_logger
from core.signals import get_signals
# 로거 설정
logger = get_logger(__name__)
class SyncStatus(Enum):
"""동기화 상태"""
IDLE = "idle"
SYNCING = "syncing"
SUCCESS = "success"
FAILED = "failed"
OFFLINE = "offline"
class SyncDirection(Enum):
"""동기화 방향"""
UPLOAD = "upload" # 로컬 -> 원격
DOWNLOAD = "download" # 원격 -> 로컬
BOTH = "both" # 양방향
class BaseSyncManager(ABC):
"""
동기화 관리자 추상 클래스
원격 데이터베이스와의 동기화 인터페이스를 정의합니다.
"""
@abstractmethod
def connect(self) -> bool:
"""원격 데이터베이스에 연결합니다."""
pass
@abstractmethod
def disconnect(self):
"""연결을 종료합니다."""
pass
@abstractmethod
def is_connected(self) -> bool:
"""연결 상태를 확인합니다."""
pass
@abstractmethod
def sync_table(self, table_name: str, direction: SyncDirection) -> bool:
"""테이블을 동기화합니다."""
pass
@abstractmethod
def sync_all(self, direction: SyncDirection) -> bool:
"""모든 테이블을 동기화합니다."""
pass
@abstractmethod
def get_last_sync_time(self, table_name: str = None) -> Optional[datetime]:
"""마지막 동기화 시간을 반환합니다."""
pass
class LocalOnlySyncManager(BaseSyncManager):
"""
로컬 전용 동기화 관리자 (프로토타입)
원격 연결 없이 로컬 데이터베이스만 사용합니다.
Supabase 연동 전까지 클래스를 사용합니다.
"""
def __init__(self):
"""초기화"""
self.signals = get_signals()
self._status = SyncStatus.OFFLINE
self._last_sync_time: Dict[str, datetime] = {}
logger.info("LocalOnlySyncManager 초기화 (원격 동기화 비활성화)")
def connect(self) -> bool:
"""
연결 시도 (항상 오프라인 상태)
Returns:
항상 False
"""
logger.info("원격 동기화가 비활성화되어 있습니다.")
self._status = SyncStatus.OFFLINE
return False
def disconnect(self):
"""연결 종료 (동작 없음)"""
pass
def is_connected(self) -> bool:
"""
연결 상태 확인
Returns:
항상 False
"""
return False
def sync_table(self, table_name: str, direction: SyncDirection) -> bool:
"""
테이블 동기화 (동작 없음)
Args:
table_name: 테이블 이름
direction: 동기화 방향
Returns:
항상 True (로컬 데이터는 항상 최신)
"""
self._last_sync_time[table_name] = datetime.now()
return True
def sync_all(self, direction: SyncDirection) -> bool:
"""
전체 동기화 (동작 없음)
Args:
direction: 동기화 방향
Returns:
항상 True
"""
logger.info("로컬 전용 모드: 동기화 건너뛰기")
return True
def get_last_sync_time(self, table_name: str = None) -> Optional[datetime]:
"""
마지막 동기화 시간 반환
Args:
table_name: 테이블 이름
Returns:
마지막 동기화 시간
"""
if table_name:
return self._last_sync_time.get(table_name)
return max(self._last_sync_time.values()) if self._last_sync_time else None
@property
def status(self) -> SyncStatus:
"""현재 동기화 상태"""
return self._status
class SupabaseSyncManager(BaseSyncManager):
"""
Supabase 동기화 관리자
추후 Supabase 연동 구현될 예정입니다.
Note:
클래스는 현재 스텁으로만 존재합니다.
실제 구현 supabase 패키지를 사용합니다.
"""
def __init__(self, url: str, key: str):
"""
초기화
Args:
url: Supabase 프로젝트 URL
key: Supabase API
"""
self.url = url
self.key = key
self.signals = get_signals()
self._client = None
self._status = SyncStatus.IDLE
self._last_sync_time: Dict[str, datetime] = {}
logger.info("SupabaseSyncManager 초기화 (추후 구현 예정)")
def connect(self) -> bool:
"""
Supabase에 연결합니다.
Returns:
연결 성공 여부
TODO:
supabase 패키지를 사용하여 실제 연결 구현
"""
# TODO: 실제 연결 구현
# from supabase import create_client
# self._client = create_client(self.url, self.key)
logger.warning("Supabase 연결 미구현")
return False
def disconnect(self):
"""연결을 종료합니다."""
self._client = None
self._status = SyncStatus.IDLE
def is_connected(self) -> bool:
"""연결 상태를 확인합니다."""
return self._client is not None
def sync_table(self, table_name: str, direction: SyncDirection) -> bool:
"""
테이블을 동기화합니다.
Args:
table_name: 테이블 이름
direction: 동기화 방향
Returns:
동기화 성공 여부
TODO:
- 마지막 동기화 시간 이후 변경된 레코드 조회
- 충돌 해결 로직 구현
- 배치 처리 구현
"""
# TODO: 실제 동기화 구현
logger.warning(f"테이블 동기화 미구현: {table_name}")
return False
def sync_all(self, direction: SyncDirection) -> bool:
"""
모든 테이블을 동기화합니다.
Args:
direction: 동기화 방향
Returns:
동기화 성공 여부
"""
tables = [
"instructions", "faults", "works", "miscs",
"daily_inspections", "todos", "memos"
]
success = True
for table in tables:
if not self.sync_table(table, direction):
success = False
if success:
self.signals.sync_completed.emit()
else:
self.signals.sync_error.emit("일부 테이블 동기화 실패")
return success
def get_last_sync_time(self, table_name: str = None) -> Optional[datetime]:
"""마지막 동기화 시간을 반환합니다."""
if table_name:
return self._last_sync_time.get(table_name)
return max(self._last_sync_time.values()) if self._last_sync_time else None
@property
def status(self) -> SyncStatus:
"""현재 동기화 상태"""
return self._status
def get_sync_manager() -> BaseSyncManager:
"""
동기화 관리자 인스턴스를 반환합니다.
현재는 LocalOnlySyncManager를 반환합니다.
추후 설정에 따라 SupabaseSyncManager를 반환할 있습니다.
Returns:
동기화 관리자 인스턴스
"""
# TODO: 설정에 따라 적절한 관리자 반환
return LocalOnlySyncManager()

BIN
dist/HandoverSystem/HandoverSystem.exe vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More