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:
477
app/file_processor.py
Normal file
477
app/file_processor.py
Normal 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)
|
||||
Reference in New Issue
Block a user