feat: Flask 애플리케이션 모듈화 및 웹 대시보드 구현

- Flask Blueprint 아키텍처로 전환 (dashboard, upload, backup, status)
- app.py 681줄  95줄로 축소 (86% 감소)
- HTML 템플릿 모듈화 (base.html + 기능별 templates)
- CSS/JS 파일 분리 (common + 기능별 파일)
- 대시보드 기능 추가 (통계, 주간 예보, 방문객 추이)
- 파일 업로드 웹 인터페이스 구현
- 백업/복구 관리 UI 구현
- Docker 배포 환경 개선
- .gitignore 업데이트 (uploads, backups, cache 등)
This commit is contained in:
2025-12-26 17:31:37 +09:00
parent 9dab27529d
commit 7121f250bc
46 changed files with 6345 additions and 191 deletions

477
app/file_processor.py Normal file
View File

@ -0,0 +1,477 @@
# app/file_processor.py
"""
POS 데이터 파일 처리 및 검증 모듈
지원 형식:
- UPSOLUTION: POS 데이터 (pos_update_upsolution.py에서 처리)
- OKPOS: 일자별 상품별 파일, 영수증별매출상세현황 파일
"""
import os
import sys
import logging
import pandas as pd
from datetime import datetime
from pathlib import Path
import subprocess
# 프로젝트 루트 경로 추가
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from conf import db, db_schema
from lib.common import setup_logging
from lib.pos_update_daily_product import process_okpos_file
from lib.pos_update_upsolution import process_upsolution_file
logger = setup_logging('file_processor', 'INFO')
class FileProcessor:
"""POS 데이터 파일 처리 클래스"""
# 지원하는 파일 확장자
ALLOWED_EXTENSIONS = {'.xlsx', '.xls', '.csv'}
# OKPOS 파일 패턴
OKPOS_PATTERNS = {
'일자별': '일자별.*상품별.*파일',
'영수증별': '영수증별매출상세현황'
}
# UPSOLUTION 파일 패턴
UPSOLUTION_PATTERNS = {'UPSOLUTION'}
def __init__(self, upload_folder):
"""
초기화
Args:
upload_folder (str): 파일 업로드 폴더 경로
"""
self.upload_folder = upload_folder
self.backup_folder = os.path.join(os.path.dirname(__file__), '..', 'dbbackup')
os.makedirs(self.backup_folder, exist_ok=True)
logger.info(f"파일 프로세서 초기화 - 업로드폴더: {upload_folder}")
def get_file_type(self, filename):
"""
파일 타입 판정
Args:
filename (str): 파일명
Returns:
str: 파일 타입 ('upsolution', 'okpos', 'unknown')
"""
filename_upper = filename.upper()
# UPSOLUTION 파일 확인
if 'UPSOLUTION' in filename_upper:
logger.debug(f"UPSOLUTION 파일 감지: {filename}")
return 'upsolution'
# OKPOS 파일 확인
if '일자별' in filename and '상품별' in filename:
logger.debug(f"OKPOS 파일(일자별) 감지: {filename}")
return 'okpos'
if '영수증별매출상세현황' in filename:
logger.debug(f"OKPOS 파일(영수증별) 감지: {filename}")
return 'okpos'
logger.warning(f"알 수 없는 파일 타입: {filename}")
return 'unknown'
def validate_file(self, filename):
"""
파일 검증
Args:
filename (str): 파일명
Returns:
tuple[bool, str]: (성공 여부, 메시지)
"""
logger.info(f"파일 검증 시작: {filename}")
# 파일 확장자 확인
_, ext = os.path.splitext(filename)
if ext.lower() not in self.ALLOWED_EXTENSIONS:
msg = f"지원하지 않는 파일 형식: {ext}"
logger.warning(msg)
return False, msg
# 파일 타입 확인
file_type = self.get_file_type(filename)
if file_type == 'unknown':
msg = f"파일명을 인식할 수 없습니다. (UPSOLUTION 또는 일자별 상품별 파일이어야 함)"
logger.warning(msg)
return False, msg
logger.info(f"파일 검증 완료: {filename} ({file_type})")
return True, file_type
def process_uploads(self, uploaded_files):
"""
여러 파일 처리
Args:
uploaded_files (list): 업로드된 파일 객체 리스트
Returns:
dict: 처리 결과
{
'files': List[dict], # 처리된 파일
'errors': List[dict] # 에러 정보
}
"""
logger.info(f"파일 처리 시작 - {len(uploaded_files)}개 파일")
files_result = []
errors_result = []
for file_obj in uploaded_files:
try:
filename = secure_filename(file_obj.filename)
logger.info(f"파일 처리: {filename}")
# 파일 검증
is_valid, file_type_or_msg = self.validate_file(filename)
if not is_valid:
logger.error(f"파일 검증 실패: {filename} - {file_type_or_msg}")
errors_result.append({
'filename': filename,
'message': file_type_or_msg,
'type': 'validation'
})
files_result.append({
'filename': filename,
'status': 'failed',
'message': file_type_or_msg
})
continue
file_type = file_type_or_msg
# 파일 저장
filepath = os.path.join(self.upload_folder, filename)
file_obj.save(filepath)
logger.info(f"파일 저장 완료: {filepath}")
# 파일 처리
result = self.process_file(filepath, file_type)
if result['success']:
logger.info(f"파일 처리 완료: {filename}")
files_result.append({
'filename': filename,
'status': 'success',
'message': result['message'],
'rows_inserted': result.get('rows_inserted', 0)
})
# 파일 삭제
try:
os.remove(filepath)
logger.info(f"임시 파일 삭제: {filepath}")
except Exception as e:
logger.warning(f"임시 파일 삭제 실패: {filepath} - {e}")
else:
logger.error(f"파일 처리 실패: {filename} - {result['message']}")
files_result.append({
'filename': filename,
'status': 'failed',
'message': result['message']
})
errors_result.append({
'filename': filename,
'message': result['message'],
'type': 'processing'
})
except Exception as e:
logger.error(f"파일 처리 중 예외 발생: {file_obj.filename} - {e}", exc_info=True)
errors_result.append({
'filename': file_obj.filename,
'message': f'처리 중 오류: {str(e)}',
'type': 'exception'
})
files_result.append({
'filename': file_obj.filename,
'status': 'failed',
'message': str(e)
})
logger.info(f"파일 처리 완료 - 성공: {len([f for f in files_result if f['status'] == 'success'])}, 실패: {len(errors_result)}")
return {
'files': files_result,
'errors': errors_result
}
def process_file(self, filepath, file_type):
"""
개별 파일 처리
Args:
filepath (str): 파일 경로
file_type (str): 파일 타입
Returns:
dict: 처리 결과
{
'success': bool,
'message': str,
'rows_inserted': int
}
"""
try:
if file_type == 'okpos':
logger.info(f"OKPOS 파일 처리: {filepath}")
return self._process_okpos_file(filepath)
elif file_type == 'upsolution':
logger.info(f"UPSOLUTION 파일 처리: {filepath}")
return self._process_upsolution_file(filepath)
else:
msg = f"지원하지 않는 파일 타입: {file_type}"
logger.error(msg)
return {
'success': False,
'message': msg
}
except Exception as e:
logger.error(f"파일 처리 중 예외 발생: {filepath} - {e}", exc_info=True)
return {
'success': False,
'message': f'파일 처리 중 오류: {str(e)}'
}
def _process_okpos_file(self, filepath):
"""
OKPOS 파일 처리
Args:
filepath (str): 파일 경로
Returns:
dict: 처리 결과
"""
try:
logger.info(f"OKPOS 파일 처리 시작: {filepath}")
# process_okpos_file 함수 호출
result = process_okpos_file(filepath)
logger.info(f"OKPOS 파일 처리 완료: {filepath} - {result.get('rows_inserted', 0)}")
return {
'success': True,
'message': f"{result.get('rows_inserted', 0)} 행이 저장되었습니다.",
'rows_inserted': result.get('rows_inserted', 0)
}
except Exception as e:
logger.error(f"OKPOS 파일 처리 오류: {filepath} - {e}", exc_info=True)
return {
'success': False,
'message': f'OKPOS 파일 처리 오류: {str(e)}'
}
def _process_upsolution_file(self, filepath):
"""
UPSOLUTION 파일 처리
Args:
filepath (str): 파일 경로
Returns:
dict: 처리 결과
"""
try:
logger.info(f"UPSOLUTION 파일 처리 시작: {filepath}")
# process_upsolution_file 함수 호출
result = process_upsolution_file(filepath)
logger.info(f"UPSOLUTION 파일 처리 완료: {filepath} - {result.get('rows_inserted', 0)}")
return {
'success': True,
'message': f"{result.get('rows_inserted', 0)} 행이 저장되었습니다.",
'rows_inserted': result.get('rows_inserted', 0)
}
except Exception as e:
logger.error(f"UPSOLUTION 파일 처리 오류: {filepath} - {e}", exc_info=True)
return {
'success': False,
'message': f'UPSOLUTION 파일 처리 오류: {str(e)}'
}
def create_database_backup(self):
"""
데이터베이스 백업 생성
Returns:
str: 백업 파일 경로
"""
try:
logger.info("데이터베이스 백업 시작")
# 백업 파일명: backup_YYYYMMDD_HHMMSS.sql
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_filename = f"backup_{timestamp}.sql"
backup_path = os.path.join(self.backup_folder, backup_filename)
# 데이터베이스 설정 로드
config = db.load_config()
db_cfg = config['database']
# mysqldump 명령 실행
cmd = [
'mysqldump',
'-h', db_cfg['host'],
'-u', db_cfg['user'],
f'-p{db_cfg["password"]}',
db_cfg['name'],
'-v'
]
logger.info(f"백업 명령 실행: mysqldump ...")
with open(backup_path, 'w', encoding='utf-8') as f:
process = subprocess.run(
cmd,
stdout=f,
stderr=subprocess.PIPE,
text=True
)
if process.returncode != 0:
error_msg = process.stderr
logger.error(f"백업 실패: {error_msg}")
raise Exception(f"백업 실패: {error_msg}")
file_size = os.path.getsize(backup_path)
logger.info(f"데이터베이스 백업 완료: {backup_path} ({file_size} bytes)")
return backup_path
except Exception as e:
logger.error(f"데이터베이스 백업 오류: {e}", exc_info=True)
raise
def restore_database_backup(self, filename):
"""
데이터베이스 복구
Args:
filename (str): 백업 파일명
Returns:
bool: 복구 성공 여부
"""
try:
backup_path = os.path.join(self.backup_folder, filename)
if not os.path.exists(backup_path):
logger.error(f"백업 파일 없음: {backup_path}")
return False
logger.info(f"데이터베이스 복구 시작: {backup_path}")
# 데이터베이스 설정 로드
config = db.load_config()
db_cfg = config['database']
# mysql 명령 실행
cmd = [
'mysql',
'-h', db_cfg['host'],
'-u', db_cfg['user'],
f'-p{db_cfg["password"]}',
db_cfg['name']
]
logger.info(f"복구 명령 실행: mysql ...")
with open(backup_path, 'r', encoding='utf-8') as f:
process = subprocess.run(
cmd,
stdin=f,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if process.returncode != 0:
error_msg = process.stderr
logger.error(f"복구 실패: {error_msg}")
return False
logger.info(f"데이터베이스 복구 완료: {filename}")
return True
except Exception as e:
logger.error(f"데이터베이스 복구 오류: {e}", exc_info=True)
return False
def list_database_backups(self):
"""
사용 가능한 백업 목록 조회
Returns:
list: 백업 파일 정보 리스트
[
{
'filename': str,
'size': int,
'created': str
}
]
"""
try:
logger.debug("백업 목록 조회")
backups = []
for filename in os.listdir(self.backup_folder):
filepath = os.path.join(self.backup_folder, filename)
if os.path.isfile(filepath) and filename.endswith('.sql'):
stat = os.stat(filepath)
backups.append({
'filename': filename,
'size': stat.st_size,
'created': datetime.fromtimestamp(stat.st_mtime).isoformat()
})
# 최신순 정렬
backups.sort(key=lambda x: x['created'], reverse=True)
logger.debug(f"백업 목록: {len(backups)}")
return backups
except Exception as e:
logger.error(f"백업 목록 조회 오류: {e}", exc_info=True)
return []
def secure_filename(filename):
"""
파일명 보안 처리
Args:
filename (str): 원본 파일명
Returns:
str: 보안 처리된 파일명
"""
# 경로 트래버설 방지
from werkzeug.utils import secure_filename as werkzeug_secure
return werkzeug_secure(filename)