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

View File

@ -1,13 +1,17 @@
import os, sys
import os, sys
import requests
import json
from datetime import datetime, timedelta
from sqlalchemy import select, func, and_
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.exc import IntegrityError
import traceback
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from conf import db, db_schema
from requests_utils import make_requests_session
CACHE_FILE = os.path.join(os.path.dirname(__file__), '..', 'cache', 'air_num_rows.json')
class AirQualityCollector:
def __init__(self, config, engine, table):
@ -20,6 +24,24 @@ class AirQualityCollector:
self.engine = engine
self.table = table
self.yesterday = (datetime.now() - timedelta(days=1)).date()
self.session = make_requests_session()
# load cache
self._num_rows_cache = {}
try:
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
self._num_rows_cache = json.load(f)
except Exception:
self._num_rows_cache = {}
def _save_num_rows_cache(self):
try:
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
json.dump(self._num_rows_cache, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"[WARN] num_rows 캐시 저장 실패: {e}")
def get_latest_date(self, conn, station):
try:
@ -30,6 +52,7 @@ class AirQualityCollector:
return result
except Exception as e:
print(f"[ERROR] 가장 최근 날짜 조회 실패: {e}")
traceback.print_exc()
return None
def save_data_to_db(self, items, conn, station):
@ -37,7 +60,7 @@ class AirQualityCollector:
try:
item_date = datetime.strptime(item['msurDt'], '%Y-%m-%d').date()
except Exception as e:
print(f"[ERROR] 날짜 파싱 오류: {item.get('msurDt')} {e}")
print(f"[ERROR] 날짜 파싱 오류: {item.get('msurDt')} {e}")
continue
data = {
@ -53,7 +76,7 @@ class AirQualityCollector:
try:
if self.debug:
print(f"[DEBUG] {item_date} [{station}] DB 저장 시도: {data}")
print(f"[DEBUG] {item_date} [{station}] DB 저장 시도: {data}")
continue
if self.force_update:
@ -75,6 +98,7 @@ class AirQualityCollector:
print(f"[ERROR] DB 중복 오류: {e}")
except Exception as e:
print(f"[ERROR] DB 저장 실패: {e}")
traceback.print_exc()
def fetch_air_quality_data_range(self, start_date_str, end_date_str, station_name, num_of_rows=100, page_no=1):
url = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg"
@ -88,23 +112,39 @@ class AirQualityCollector:
'msrstnName': station_name,
}
resp = None
try:
resp = requests.get(url, params=params, timeout=10)
resp = self.session.get(url, params=params, timeout=20)
resp.raise_for_status()
data = resp.json()
return data.get('response', {}).get('body', {}).get('items', [])
except requests.RequestException as e:
print(f"[ERROR] 요청 실패: {e}")
return []
except ValueError as e:
print(f"[ERROR] JSON 파싱 실패: {e}")
except Exception as e:
body_preview = None
try:
if resp is not None:
body_preview = resp.text[:1000]
except Exception:
body_preview = None
print(f"[ERROR] 요청 실패: {e} status={getattr(resp, 'status_code', 'n/a')} body_preview={body_preview}")
traceback.print_exc()
return []
def test_num_of_rows(self, station_name, date_str):
# 캐시 확인
try:
cache_key = f"{station_name}:{date_str}"
if cache_key in self._num_rows_cache:
val = int(self._num_rows_cache[cache_key])
print(f"[INFO] 캐시된 numOfRows 사용: {val} ({cache_key})")
return val
except Exception:
pass
max_rows = 1000
min_rows = 100
while max_rows >= min_rows:
resp = None
try:
url = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg"
params = {
@ -116,13 +156,25 @@ class AirQualityCollector:
'inqEndDt': date_str,
'msrstnName': station_name,
}
resp = requests.get(url, params=params, timeout=10)
resp = self.session.get(url, params=params, timeout=20)
resp.raise_for_status()
resp.json()
resp.json() # 성공하면 해당 max_rows 사용 가능
print(f"[INFO] numOfRows 최대값 탐색 성공: {max_rows}")
# 캐시에 저장
try:
self._num_rows_cache[f"{station_name}:{date_str}"] = max_rows
self._save_num_rows_cache()
except Exception:
pass
return max_rows
except Exception as e:
print(f"[WARN] numOfRows={max_rows} 실패: {e}, 100 감소 후 재시도")
body_preview = None
try:
if resp is not None:
body_preview = resp.text[:500]
except Exception:
body_preview = None
print(f"[WARN] numOfRows={max_rows} 실패: {e}, body_preview={body_preview} 100 감소 후 재시도")
max_rows -= 100
print("[WARN] 최소 numOfRows 값(100)도 실패했습니다. 기본값 100 사용")
@ -160,7 +212,7 @@ class AirQualityCollector:
current_start = current_end + timedelta(days=1)
print("\n[INFO] 모든 측정소 데이터 처리 완료")
if __name__ == '__main__':
config = db.load_config()
collector = AirQualityCollector(config, db.engine, db_schema.air)

View File

@ -1,31 +1,136 @@
# common.py
import os, yaml
import os
import yaml
import logging
import time
import glob
from functools import wraps
from typing import Any, Callable
def load_config():
# 로거 설정
def setup_logging(name: str, level: str = 'INFO') -> logging.Logger:
"""
conf/config.yaml 파일을 UTF-8로 읽어 파이썬 dict로 반환
로거 설정 (일관된 포맷 적용)
Args:
name: 로거 이름
level: 로그 레벨 (INFO, DEBUG, WARNING, ERROR)
Returns:
Logger 인스턴스
"""
path = os.path.join(os.path.dirname(__file__), '..', 'conf', 'config.yaml')
with open(path, encoding='utf-8') as f:
return yaml.safe_load(f)
def get_logger(name):
logger = logging.getLogger(name)
if not logger.handlers:
handler = logging.StreamHandler()
formatter = logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s')
formatter = logging.Formatter(
'[%(asctime)s] %(name)s - %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
return logger
def wait_download_complete(download_dir, ext, timeout=60):
for _ in range(timeout):
files = glob.glob(os.path.join(download_dir, f"*.{ext.strip('.')}"))
def get_logger(name: str) -> logging.Logger:
"""기존 호환성 유지"""
return setup_logging(name)
def load_config(config_path: str = None) -> dict:
"""
conf/config.yaml 파일을 UTF-8로 읽어 파이썬 dict로 반환
Args:
config_path: 설정 파일 경로 (없으면 기본값 사용)
Returns:
설정 딕셔너리
Raises:
FileNotFoundError: 설정 파일을 찾을 수 없을 때
yaml.YAMLError: YAML 파싱 실패 시
"""
if config_path is None:
config_path = os.path.join(os.path.dirname(__file__), '..', 'conf', 'config.yaml')
try:
with open(config_path, encoding='utf-8') as f:
config = yaml.safe_load(f)
if config is None:
raise ValueError(f"설정 파일이 비어있음: {config_path}")
return config
except FileNotFoundError:
raise FileNotFoundError(f"설정 파일을 찾을 수 없음: {config_path}")
except yaml.YAMLError as e:
raise yaml.YAMLError(f"YAML 파싱 오류: {e}")
def retry_on_exception(max_retries: int = 3, delay: float = 1.0, backoff: float = 2.0):
"""
지정된 횟수만큼 재시도하는 데코레이터
Args:
max_retries: 최대 재시도 횟수
delay: 재시도 간격 (초)
backoff: 재시도마다 지연 시간 배수
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
logger = logging.getLogger(func.__module__)
last_exception = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
wait_time = delay * (backoff ** attempt)
logger.warning(
f"{func.__name__} 재시도 {attempt + 1}/{max_retries} "
f"({wait_time:.1f}초 대기): {e}"
)
time.sleep(wait_time)
else:
logger.error(
f"{func.__name__} 모든 재시도 실패: {e}"
)
raise last_exception
return wrapper
return decorator
def wait_download_complete(download_dir: str, ext: str, timeout: int = 60) -> str:
"""
파일 다운로드 완료 대기
Args:
download_dir: 다운로드 디렉토리
ext: 파일 확장자 (예: 'xlsx', 'csv')
timeout: 대기 시간 (초)
Returns:
다운로드된 파일 경로
Raises:
TimeoutError: 지정 시간 내 파일이 나타나지 않을 때
"""
logger = logging.getLogger(__name__)
ext = ext.lstrip('.')
for i in range(timeout):
files = glob.glob(os.path.join(download_dir, f"*.{ext}"))
if files:
logger.info(f"다운로드 완료: {files[0]}")
return files[0]
if i > 0 and i % 10 == 0:
logger.debug(f"다운로드 대기 중... ({i}초 경과)")
time.sleep(1)
raise TimeoutError("다운로드 대기 시간 초과")
raise TimeoutError(
f"파일 다운로드 대기 시간 초과 ({timeout}초): {download_dir}/*.{ext}"
)

View File

@ -1,7 +1,7 @@
# ga4.py
# ga4.py
'''
퍼스트가든 구글 애널리틱스 API를 활용해 관련 데이터를 DB에 저장함
병렬 처리를 통해 처리 속도 향상
병렬 처리를 통해 처리 속도 향상 (내부 병렬은 유지하되 에러/재시도 보강)
'''
import sys, os
@ -9,6 +9,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import yaml
import pprint
import traceback
from datetime import datetime, timedelta
from dateutil.parser import parse
from google.analytics.data import BetaAnalyticsDataClient
@ -38,25 +39,33 @@ def load_config():
# GA4 클라이언트 초기화
# ------------------------
def init_ga_client(service_account_file):
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = service_account_file
print(f"[INFO] GA4 클라이언트 초기화 - 인증파일: {service_account_file}")
return BetaAnalyticsDataClient()
try:
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = service_account_file
print(f"[INFO] GA4 클라이언트 초기화 - 인증파일: {service_account_file}")
return BetaAnalyticsDataClient()
except Exception as e:
print(f"[ERROR] GA4 클라이언트 초기화 실패: {e}")
traceback.print_exc()
raise
# ------------------------
# config.yaml에 최대 rows 저장
# ------------------------
def update_config_file_with_max_rows(max_rows):
with open(CONFIG_PATH, encoding="utf-8") as f:
config = yaml.safe_load(f)
try:
with open(CONFIG_PATH, encoding="utf-8") as f:
config = yaml.safe_load(f)
if "ga4" not in config:
config["ga4"] = {}
config["ga4"]["max_rows_per_request"] = max_rows
if "ga4" not in config:
config["ga4"] = {}
config["ga4"]["max_rows_per_request"] = int(max_rows)
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
yaml.dump(config, f, allow_unicode=True)
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
yaml.dump(config, f, allow_unicode=True)
print(f"[INFO] config.yaml에 max_rows_per_request = {max_rows} 저장 완료")
print(f"[INFO] config.yaml에 max_rows_per_request = {max_rows} 저장 완료")
except Exception as e:
print(f"[WARN] config.yaml 업데이트 실패: {e}")
# ------------------------
# GA4 API로 최대 rows 감지
@ -71,10 +80,13 @@ def detect_max_rows_supported(client, property_id):
limit=100000
)
response = client.run_report(request)
print(f"[INFO] 최대 rows 감지: {len(response.rows)} rows 수신됨.")
return len(response.rows)
nrows = len(response.rows)
print(f"[INFO] 최대 rows 감지: {nrows} rows 수신됨.")
return nrows
except Exception as e:
print(f"[WARNING] 최대 rows 감지 실패: {e}")
traceback.print_exc()
# 안전한 기본값 반환
return 10000
# ------------------------
@ -82,21 +94,31 @@ def detect_max_rows_supported(client, property_id):
# ------------------------
def fetch_report(client, property_id, start_date, end_date, dimensions, metrics, limit=10000):
print(f"[INFO] fetch_report 호출 - 기간: {start_date} ~ {end_date}, dims={dimensions}, metrics={metrics}")
request = RunReportRequest(
property=f"properties/{property_id}",
dimensions=[Dimension(name=d) for d in dimensions],
metrics=[Metric(name=m) for m in metrics],
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
limit=limit,
)
response = client.run_report(request)
print(f"[INFO] GA4 리포트 응답 받음: {len(response.rows)} rows")
return response
try:
request = RunReportRequest(
property=f"properties/{property_id}",
dimensions=[Dimension(name=d) for d in dimensions],
metrics=[Metric(name=m) for m in metrics],
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
limit=limit,
)
response = client.run_report(request)
print(f"[INFO] GA4 리포트 응답 받음: {len(response.rows)} rows")
return response
except Exception as e:
print(f"[ERROR] GA4 fetch_report 실패: {e}")
traceback.print_exc()
# 빈 응답 형태 반환하는 대신 None 반환해서 호출부가 처리하도록 함
return None
# ------------------------
# 응답 데이터를 DB에 저장
# ------------------------
def save_report_to_db(engine, table, response, dimension_names, metric_names, debug=False):
if response is None:
print("[INFO] 저장할 응답 없음 (None)")
return
with engine.begin() as conn:
for row in response.rows:
dims = row.dimension_values
@ -137,6 +159,7 @@ def save_report_to_db(engine, table, response, dimension_names, metric_names, de
print(f"[DB ERROR] 중복 오류 또는 기타: {e}")
except Exception as e:
print(f"[DB ERROR] 저장 실패: {e}")
traceback.print_exc()
# ------------------------
# 테이블에서 마지막 날짜 조회
@ -175,7 +198,6 @@ def determine_date_range(table, config_start, config_end, force_update, engine):
else:
actual_start = config_start
# 시작일이 종료일보다 뒤에 있으면 자동 교체
if actual_start > actual_end:
print(f"[WARN] 시작일({actual_start})이 종료일({actual_end})보다 뒤에 있습니다. 날짜를 교환하여 수집을 계속합니다.")
actual_start, actual_end = actual_end, actual_start
@ -201,10 +223,10 @@ def process_dimension_metric(engine, client, property_id, config, table, dims, m
end_str = end_dt.strftime('%Y-%m-%d')
print(f"[INFO] GA4 데이터 조회: {start_str} ~ {end_str}")
response = fetch_report(client, property_id, start_str, end_str, dimensions=dims, metrics=mets, limit=max_rows)
if len(response.rows) > 0:
if response and len(response.rows) > 0:
save_report_to_db(engine, table, response, dimension_names=dims, metric_names=mets, debug=debug)
else:
print(f"[INFO] 해당 기간 {start_str} ~ {end_str} 데이터 없음")
print(f"[INFO] 해당 기간 {start_str} ~ {end_str} 데이터 없음 또는 요청 실패")
# ------------------------
# 메인 진입점 (병렬 처리 포함)
@ -224,12 +246,19 @@ def main():
return
engine = db.engine
client = init_ga_client(service_account_file)
try:
client = init_ga_client(service_account_file)
except Exception:
print("[ERROR] GA4 클라이언트 초기화 실패로 종료합니다.")
return
max_rows = ga4_cfg.get("max_rows_per_request")
if not max_rows:
max_rows = detect_max_rows_supported(client, property_id)
update_config_file_with_max_rows(max_rows)
try:
update_config_file_with_max_rows(max_rows)
except Exception:
pass
print(f"[INFO] 설정된 max_rows_per_request = {max_rows}")
tasks = [
@ -253,6 +282,7 @@ def main():
print(f"[INFO] 태스크 {i} 완료")
except Exception as e:
print(f"[ERROR] 태스크 {i} 실패: {e}")
traceback.print_exc()
print("[INFO] GA4 데이터 수집 및 저장 완료")

View File

@ -18,6 +18,14 @@ CONFIG = load_config()
DATA_DIR = os.path.join(os.path.dirname(__file__), '../data')
def update_pos_table(engine, table, df):
"""
데이터프레임을 테이블에 업데이트
Args:
engine: SQLAlchemy 엔진
table: DB 테이블 객체
df: 데이터프레임
"""
with engine.begin() as conn:
for idx, row in df.iterrows():
data = row.to_dict()
@ -39,6 +47,17 @@ def update_pos_table(engine, table, df):
print("[DONE] 모든 데이터 삽입 완료")
def process_file(filepath, table, engine):
"""
OKPOS 파일 처리
Args:
filepath: 파일 경로
table: DB 테이블
engine: SQLAlchemy 엔진
Returns:
tuple[bool, int]: (성공 여부, 행 수)
"""
print(f"[INFO] 처리 시작: {filepath}")
try:
ext = os.path.splitext(filepath)[-1].lower()
@ -86,6 +105,51 @@ def process_file(filepath, table, engine):
print(f"[INFO] 처리 완료: {filepath}")
return True, len(df)
def process_okpos_file(filepath):
"""
OKPOS 파일을 처리하고 DB에 저장
웹 업로드 인터페이스에서 사용하는 함수
Args:
filepath (str): 업로드된 파일 경로
Returns:
dict: {
'success': bool,
'message': str,
'rows_inserted': int
}
"""
try:
engine = db.engine
table = db_schema.pos
# 파일 처리
success, row_count = process_file(filepath, table, engine)
if success:
return {
'success': True,
'message': f'{row_count}행이 저장되었습니다.',
'rows_inserted': row_count
}
else:
return {
'success': False,
'message': '파일 처리에 실패했습니다.',
'rows_inserted': 0
}
except Exception as e:
print(f"[ERROR] OKPOS 파일 처리 오류: {e}")
return {
'success': False,
'message': f'파일 처리 중 오류: {str(e)}',
'rows_inserted': 0
}
def batch_process_files(table, engine):
files = [f for f in os.listdir(DATA_DIR) if f.startswith("일자별 (상품별)") and f.endswith(('.xlsx', '.xls'))]
if not files:

View File

@ -71,9 +71,21 @@ def prepare_bulk_data(df):
def process_bulk_upsert(bulk_data, session, table, batch_size=1000):
"""
데이터 일괄 삽입/업데이트
Args:
bulk_data: 삽입할 데이터 리스트
session: DB 세션
table: 대상 테이블
batch_size: 배치 크기
Returns:
int: 삽입된 행 수
"""
if not bulk_data:
logger.info("[INFO] 삽입할 데이터가 없습니다.")
return
return 0
total = len(bulk_data)
inserted_total = 0
@ -96,8 +108,11 @@ def process_bulk_upsert(bulk_data, session, table, batch_size=1000):
raise
logger.info(f"[DONE] 총 {total}건 처리 완료 (insert+update)")
return inserted_total
def file_reader(queue, files):
"""파일 읽기 스레드"""
for filepath in files:
try:
logger.info(f"[READ] {os.path.basename(filepath)} 읽기 시작")
@ -111,6 +126,7 @@ def file_reader(queue, files):
def db_writer(queue, session, table):
"""DB 쓰기 스레드"""
while True:
item = queue.get()
if item is None:
@ -126,6 +142,66 @@ def db_writer(queue, session, table):
logger.error(f"[FAIL] {os.path.basename(filepath)} DB 삽입 실패 - {e}")
def process_upsolution_file(filepath):
"""
UPSOLUTION 파일을 처리하고 DB에 저장
웹 업로드 인터페이스에서 사용하는 함수
Args:
filepath (str): 업로드된 파일 경로
Returns:
dict: {
'success': bool,
'message': str,
'rows_inserted': int
}
"""
try:
logger.info(f"[WEB] UPSOLUTION 파일 처리 시작: {filepath}")
# 파일 읽기
df = load_excel_data(filepath)
logger.info(f"[WEB] 데이터 읽기 완료: {len(df)}")
# 데이터 준비
bulk_data = prepare_bulk_data(df)
logger.info(f"[WEB] 데이터 준비 완료: {len(bulk_data)}")
# DB 세션 및 테이블 가져오기
session = db.get_session()
engine = db.get_engine()
metadata = MetaData()
table = Table(
db_schema.get_full_table_name("pos_ups_billdata"),
metadata,
autoload_with=engine
)
# 데이터 삽입
inserted = process_bulk_upsert(bulk_data, session, table)
session.close()
logger.info(f"[WEB] UPSOLUTION 파일 처리 완료: {inserted}행 삽입")
return {
'success': True,
'message': f'{inserted}행이 저장되었습니다.',
'rows_inserted': inserted
}
except Exception as e:
logger.error(f"[WEB] UPSOLUTION 파일 처리 오류: {e}", exc_info=True)
return {
'success': False,
'message': f'파일 처리 중 오류: {str(e)}',
'rows_inserted': 0
}
def main():
engine = db.get_engine()
session = db.get_session()
@ -142,7 +218,7 @@ def main():
logger.info(f"[INFO] 처리할 파일 {len(files)}")
queue = Queue(maxsize=2) # 2개 정도 여유 있게
queue = Queue(maxsize=3) # 2개 정도 여유 있게
reader_thread = threading.Thread(target=file_reader, args=(queue, files))
writer_thread = threading.Thread(target=db_writer, args=(queue, session, table))

21
lib/requests_utils.py Normal file
View File

@ -0,0 +1,21 @@
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
def make_requests_session(retries=3, backoff_factor=0.5, status_forcelist=(429, 500, 502, 503, 504)):
"""
재시도(backoff)를 적용한 requests.Session 반환
"""
session = requests.Session()
retry = Retry(
total=retries,
read=retries,
connect=retries,
backoff_factor=backoff_factor,
status_forcelist=status_forcelist,
allowed_methods=frozenset(["HEAD", "GET", "OPTIONS", "POST"])
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session

44
lib/to_csv.py Normal file
View File

@ -0,0 +1,44 @@
import os, sys
import shutil
import pandas as pd
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from lib.common import get_logger
logger = get_logger("TO_CSV")
DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data"))
FINISH_DIR = os.path.join(DATA_DIR, "finish")
os.makedirs(FINISH_DIR, exist_ok=True)
def convert_excel_to_csv(filepath):
try:
logger.info(f"변환 시작: {os.path.basename(filepath)}")
df = pd.read_excel(filepath, header=1) # 2행이 헤더
df.columns = [col.strip() for col in df.columns]
csv_path = filepath + '.csv'
df.to_csv(csv_path, index=False, encoding='utf-8-sig')
logger.info(f"변환 완료: {os.path.basename(csv_path)}")
# 변환 완료된 원본 엑셀 파일 이동
dest_path = os.path.join(FINISH_DIR, os.path.basename(filepath))
shutil.move(filepath, dest_path)
logger.info(f"원본 엑셀 파일 이동 완료: {os.path.basename(dest_path)}")
except Exception as e:
logger.error(f"변환 실패: {os.path.basename(filepath)} - {e}")
def main():
files = [os.path.join(DATA_DIR, f) for f in os.listdir(DATA_DIR)
if (f.endswith(('.xls', '.xlsx')) and f.startswith("영수증별 상세매출"))]
logger.info(f"{len(files)}개 엑셀 파일 변환 시작")
for filepath in files:
convert_excel_to_csv(filepath)
logger.info("모든 파일 변환 완료")
if __name__ == "__main__":
main()

View File

@ -1,4 +1,4 @@
import sys, os
import sys, os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import yaml
@ -6,8 +6,10 @@ import requests
from datetime import datetime, timedelta, date
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy import select
import traceback
from conf import db, db_schema
from requests_utils import make_requests_session
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "../conf/config.yaml")
@ -24,7 +26,9 @@ def fetch_data_range_chunks(start_dt, end_dt, chunk_days=10):
yield current_start.strftime("%Y%m%d"), current_end.strftime("%Y%m%d")
current_start = current_end + timedelta(days=1)
def fetch_asos_data(stn_id, start_dt, end_dt, service_key):
def fetch_asos_data(stn_id, start_dt, end_dt, service_key, session=None):
if session is None:
session = make_requests_session()
url = "http://apis.data.go.kr/1360000/AsosDalyInfoService/getWthrDataList"
params = {
@ -44,8 +48,9 @@ def fetch_asos_data(stn_id, start_dt, end_dt, service_key):
"Accept": "application/json"
}
resp = None
try:
resp = requests.get(url, params=params, headers=headers, timeout=15)
resp = session.get(url, params=params, headers=headers, timeout=20)
resp.raise_for_status()
data = resp.json()
items = data.get("response", {}).get("body", {}).get("items", {}).get("item", [])
@ -57,7 +62,14 @@ def fetch_asos_data(stn_id, start_dt, end_dt, service_key):
return items
except Exception as e:
print(f"[ERROR] API 요청 실패: {e}")
body_preview = None
try:
if resp is not None:
body_preview = resp.text[:1000]
except Exception:
body_preview = None
print(f"[ERROR] ASOS API 요청 실패: {e} status={getattr(resp, 'status_code', 'n/a')} body_preview={body_preview}")
traceback.print_exc()
return []
def save_items_to_db(items, conn, table, force_update=False, debug=False):
@ -98,7 +110,7 @@ def save_items_to_db(items, conn, table, force_update=False, debug=False):
data[key] = None
if debug:
print(f"[DEBUG] {record_date} DB 저장 시도: {data}")
print(f"[DEBUG] {record_date} DB 저장 시도: {data}")
continue
if force_update:
@ -116,6 +128,7 @@ def save_items_to_db(items, conn, table, force_update=False, debug=False):
except Exception as e:
print(f"[ERROR] 저장 실패: {e}")
traceback.print_exc()
raise
def get_latest_date_from_db(conn, table):
@ -149,6 +162,8 @@ def main():
chunk_days = 1000
session = make_requests_session()
with engine.begin() as conn:
print(f"[INFO] DB 저장 최종 일자 점검")
latest_date = get_latest_date_from_db(conn, table)
@ -170,7 +185,7 @@ def main():
for stn_id in stn_ids:
for chunk_start, chunk_end in fetch_data_range_chunks(start_dt, end_dt, chunk_days):
print(f"[INFO] 지점 {stn_id} 데이터 요청 중: {chunk_start} ~ {chunk_end}")
items = fetch_asos_data(stn_id, chunk_start, chunk_end, service_key)
items = fetch_asos_data(stn_id, chunk_start, chunk_end, service_key, session=session)
if items:
save_items_to_db(items, conn, table, force_update, debug)
else: