- Flask Blueprint 아키텍처로 전환 (dashboard, upload, backup, status) - app.py 681줄 95줄로 축소 (86% 감소) - HTML 템플릿 모듈화 (base.html + 기능별 templates) - CSS/JS 파일 분리 (common + 기능별 파일) - 대시보드 기능 추가 (통계, 주간 예보, 방문객 추이) - 파일 업로드 웹 인터페이스 구현 - 백업/복구 관리 UI 구현 - Docker 배포 환경 개선 - .gitignore 업데이트 (uploads, backups, cache 등)
478 lines
16 KiB
Python
478 lines
16 KiB
Python
# 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)
|