1 Commits
master ... web

Author SHA1 Message Date
f22e5922a2 웹페이지 형식으로 데이터를 추출할 수 있는 기능 2025-07-21 17:37:35 +09:00
56 changed files with 726 additions and 7411 deletions

View File

@ -1,92 +0,0 @@
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
*~
.DS_Store
# Python
__pycache__
*.pyc
*.pyo
*.pyd
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.pytest_cache/
.coverage
htmlcov/
venv/
env/
ENV/
# Environment
.env
.env.local
.env.*.local
# Logs
logs/
*.log
*.log.*
cron.log
file_watch.log
daily_run.log
# Data (keep cached API responses)
data/cache/
data/*.csv
data/*.xlsx
data/*.xls
output/
*.db
*.sqlite
# Temporary
*.tmp
.tmp/
temp/
*.bak
*.swp
# OS
Thumbs.db
.DS_Store
.AppleDouble
.LSOverride
# Docker
docker-compose.override.yml
.dockerignore
# Test
.pytest_cache
.tox/
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Documentation
docs/_build/
site/

View File

@ -1,66 +0,0 @@
# ===== Database Configuration =====
# MariaDB 데이터베이스 연결 정보
DB_HOST=mariadb # 데이터베이스 호스트명 (Docker 서비스명 또는 localhost)
DB_PORT=3306 # MariaDB 포트 (기본값: 3306)
DB_NAME=firstgarden # 데이터베이스 이름
DB_USER=firstgarden # 데이터베이스 사용자명
DB_PASSWORD=Fg9576861! # 데이터베이스 비밀번호
DB_ROOT_PASSWORD=rootpassword # MariaDB root 비밀번호 (Docker 컨테이너용)
# ===== Database Table Configuration =====
TABLE_PREFIX=fg_manager_static_ # 테이블명 접두사
# ===== Data.go.kr API Configuration =====
# 공공데이터포털 API 키 (대기질, 날씨 데이터 수집용)
DATA_API_SERVICE_KEY=mHrZoSnzVc+2S4dpCe3A1CgI9cAu1BRttqRdoEy9RGbnKAKyQT4sqcESDqqY3grgBGQMuLeEgWIS3Qxi8rcDVA==
DATA_API_START_DATE=20170101 # 데이터 수집 시작 날짜 (YYYYMMDD)
DATA_API_END_DATE=20250701 # 데이터 수집 종료 날짜 (YYYYMMDD)
# 대기질 측정소 (쉼표로 구분)
AIR_STATION_NAMES=운정
# 날씨 관측소 ID (쉼표로 구분)
WEATHER_STN_IDS=99
# ===== Google Analytics 4 Configuration =====
# GA4 API 설정 (방문자 데이터 수집용)
GA4_API_TOKEN=AIzaSyCceJkv02KvwRKzU0IdBRlQ2zHh2yzkLkA
GA4_PROPERTY_ID=384052726 # GA4 속성 ID
GA4_SERVICE_ACCOUNT_FILE=./conf/service-account-credentials.json
GA4_START_DATE=20170101 # GA4 데이터 수집 시작 날짜
GA4_END_DATE=20990731 # GA4 데이터 수집 종료 날짜
GA4_MAX_ROWS_PER_REQUEST=10000 # 한 번에 가져올 최대 행 수
# ===== POS Configuration =====
# UPSolution POS 시스템 연동 정보
UPSOLUTION_ID=firstgarden # UPSolution 계정 ID
UPSOLUTION_CODE=1112 # UPSolution 점포 코드
UPSOLUTION_PW=9999 # UPSolution 계정 비밀번호
# 방문객 카테고리 (쉼표로 구분)
VISITOR_CATEGORIES=입장료,티켓,기업제휴
# ===== Forecast Weight Configuration =====
# 방문객 예측 모델 가중치 설정
FORECAST_VISITOR_MULTIPLIER=0.5 # 최종 예측 방문객 가중치
FORECAST_WEIGHT_MIN_TEMP=1.0 # 최저기온 가중치
FORECAST_WEIGHT_MAX_TEMP=1.0 # 최고기온 가중치
FORECAST_WEIGHT_PRECIPITATION=10.0 # 강수량 가중치
FORECAST_WEIGHT_HUMIDITY=1.0 # 습도 가중치
FORECAST_WEIGHT_PM25=1.0 # 미세먼지(PM2.5) 가중치
FORECAST_WEIGHT_HOLIDAY=20 # 휴일 여부 가중치
# ===== Application Configuration =====
MAX_WORKERS=4 # 병렬 처리 worker 수
DEBUG=false # 디버그 모드 (true/false)
FORCE_UPDATE=false # 중복 데이터 덮어쓰기 여부 (true/false)
# ===== Logging Configuration =====
LOG_LEVEL=INFO # 로그 레벨 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
# ===== Timezone Configuration =====
TZ=Asia/Seoul # 시스템 타임존
# ===== Python Configuration =====
PYTHONUNBUFFERED=1 # Python 출력 버퍼링 비활성화
PYTHONDONTWRITEBYTECODE=1 # .pyc 파일 생성 비활성화

54
.gitignore vendored
View File

@ -1,56 +1,6 @@
# ===== 설정 파일 =====
conf/config.yaml conf/config.yaml
conf/service-account-credentials.json .vscode/
.env
.env.local
# ===== Python 관련 =====
**/__pycache__/ **/__pycache__/
*.py[cod] conf/service-account-credentials.json
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
.pytest_cache/
.coverage
htmlcov/
# ===== 가상 환경 =====
venv/
env/
ENV/
.venv/
# ===== 데이터 및 출력 =====
data/ data/
output/ output/
logs/
uploads/
backups/
dbbackup/
db_data/
# ===== 캐시 =====
data/cache/
*.bak
# ===== 에디터 =====
.vscode/
.idea/
*.swp
*.swo
*~
# ===== OS =====
.DS_Store
Thumbs.db
# ===== 로그 =====
*.log
# ===== 임시 파일 =====
*.tmp
*.temp
.cache/

View File

@ -1,132 +0,0 @@
# CHANGELOG
모든 주목할만한 변경 사항이 이 파일에 기록됩니다.
## [개선사항] - 2025-12-26
### 추가됨
- ✅ 통합 로깅 시스템 (`lib/common.py``setup_logging`)
- ✅ 데이터베이스 재연결 메커니즘 (자동 풀 재설정)
- ✅ 재시도 데코레이터 (`@retry_on_exception`)
- ✅ 컨텍스트 매니저 기반 세션 관리 (`DBSession`)
- ✅ 에러 추적 및 상세 로깅
- ✅ Docker Compose MariaDB 통합
- ✅ 환경 변수 기반 설정 관리
- ✅ 헬스체크 스크립트
- ✅ 향상된 Dockerfile (Python 3.11, 슬림 이미지)
- ✅ Docker Entrypoint 개선 (신호 처리, 프로세스 모니터링)
- ✅ 포괄적인 README.md 문서
### 변경됨
- 🔄 `requirements.txt` - 모든 의존성 버전 고정
- 🔄 `daily_run.py` - 통합 로깅 및 에러 처리
- 🔄 `conf/db.py` - 연결 풀 및 재연결 설정 개선
- 🔄 `docker-compose.yml` - MariaDB 추가, 환경 변수 관리
- 🔄 `.gitignore` - 더 완전한 무시 규칙
### 제거됨
- ❌ Dockerfile의 불필요한 GUI 라이브러리 (tk 관련)
- ❌ 과도한 시스템 패키지
### 고정됨
- 🐛 DB 연결 타임아웃 문제
- 🐛 로깅 포맷 일관성
- 🐛 환경 변수 해석 오류
### 보안
- 🔐 민감한 정보를 환경 변수로 관리
- 🔐 `.env` 파일 .gitignore 추가
- 🔐 API 키 보안 강화
- 🔐 데이터베이스 암호 정책 권장
---
## 제공 예정 기능
- [ ] REST API 엔드포인트
- [ ] 실시간 대시보드
- [ ] 다중 모델 앙상블 (Prophet + ARIMA + RandomForest)
- [ ] 설명 가능한 AI (SHAP)
- [ ] 이상 탐지 (Anomaly Detection)
- [ ] GraphQL API
- [ ] WebSocket 실시간 업데이트
---
## 버전 정보
### Python Dependencies (v2025-12)
- `python` >= 3.11
- `flask` == 3.0.0
- `sqlalchemy` == 2.0.23
- `pymysql` == 1.1.0
- `pyyaml` == 6.0.1
- `pandas` == 2.1.3
- `prophet` == 1.1.5
- `scikit-learn` == 1.3.2
- 기타 상세 버전은 `requirements.txt` 참조
### Docker Image
- Base: `python:3.11-slim-bullseye`
- Size: ~300MB (예상)
---
## 마이그레이션 가이드
### v1 → v2 (현재 버전)
1. **환경 변수 설정**
```bash
cp .env.example .env
# .env 파일 수정
```
2. **기존 코드 업데이트**
```python
# 기존
from lib.common import get_logger
logger = get_logger('my_module')
# 변경
from lib.common import setup_logging
logger = setup_logging('my_module', 'INFO')
```
3. **데이터베이스 세션 관리**
```python
# 기존
session = db.get_session()
try:
# ...
finally:
session.close()
# 변경 (권장)
from conf.db import DBSession
with DBSession() as session:
# ...
```
4. **Docker 실행**
```bash
docker-compose up -d
```
---
## 알려진 문제
- [ ] Prophet 모델 학습 시간 개선 필요
- [ ] GA4 API 데이터 일관성 검증 필요
- [ ] 대용량 데이터 처리 최적화 필요
---
## 기여
버그 리포트 및 기능 요청은 Gitea Issues를 사용해주세요.
---
**마지막 업데이트**: 2025-12-26

View File

@ -1,307 +0,0 @@
# POS 데이터 웹 대시보드 - 완성 요약
## 📊 대시보드 기능
### 1. **통계 카드 (대시보드 탭)**
- **OKPOS 일자별 상품별 데이터**
- 최종 저장일
- 총 저장일수
- 총 데이터 개수
- **OKPOS 영수증별 데이터**
- 최종 저장일
- 총 저장일수
- 총 데이터 개수
- **UPSOLUTION 데이터**
- 최종 저장일
- 총 저장일수
- 총 데이터 개수
- **날씨/기상 데이터**
- 최종 저장일
- 총 저장일수
- 총 데이터 개수
### 2. **주간 예보 테이블**
- 이번 주(월요일~일요일) 날씨 및 방문객 예상
- 컬럼:
- 날짜 (YYYY-MM-DD (요일))
- 최저기온 (°C)
- 최고기온 (°C)
- 강수량 (mm)
- 습도 (%)
- 예상 방문객 (명)
### 3. **방문객 추이 그래프**
- 날짜 범위 선택 가능
- 시작 날짜: 달력 선택기
- 종료 날짜: 달력 선택기
- "조회" 버튼: 선택된 기간 데이터 표시
- "최근 1개월" 버튼: 기본값으로 초기화 (최근 30일)
- 라인 차트 표시
- X축: 날짜
- Y축: 방문객 수
- 상호작용 가능한 차트 (Chart.js 기반)
---
## 🔌 새로운 API 엔드포인트
### GET `/api/dashboard/okpos-product`
**응답:**
```json
{
"last_date": "2025-12-26",
"total_days": 45,
"total_records": 12345,
"message": "12345건의 데이터가 45일에 걸쳐 저장됨"
}
```
### GET `/api/dashboard/okpos-receipt`
**응답:**
```json
{
"last_date": "2025-12-26",
"total_days": 30,
"total_records": 5678,
"message": "5678건의 데이터가 30일에 걸쳐 저장됨"
}
```
### GET `/api/dashboard/upsolution`
**응답:**
```json
{
"last_date": "2025-12-26",
"total_days": 25,
"total_records": 8901,
"message": "8901건의 데이터가 25일에 걸쳐 저장됨"
}
```
### GET `/api/dashboard/weather`
**응답:**
```json
{
"last_date": "2025-12-26",
"total_days": 365,
"total_records": 87600,
"message": "87600건의 데이터가 365일에 걸쳐 저장됨"
}
```
### GET `/api/dashboard/weekly-forecast`
**응답:**
```json
{
"forecast_data": [
{
"date": "2025-12-29",
"day": "월",
"min_temp": 3,
"max_temp": 12,
"precipitation": 0.5,
"humidity": 65,
"expected_visitors": 245
},
...
],
"message": "2025-12-29 ~ 2026-01-04 주간 예보"
}
```
### GET `/api/dashboard/visitor-trend?start_date=2025-11-26&end_date=2025-12-26`
**파라미터:**
- `start_date` (선택): YYYY-MM-DD 형식의 시작 날짜
- `end_date` (선택): YYYY-MM-DD 형식의 종료 날짜
- `days` (선택): 조회할 일수 (기본값: 30)
**응답:**
```json
{
"dates": ["2025-11-26", "2025-11-27", ...],
"visitors": [120, 145, ...],
"message": "30일 동안 3,650명 방문"
}
```
---
## 📱 UI/UX 개선사항
### 레이아웃
- **탭 네비게이션**: 3개 탭 (대시보드, 파일 업로드, 백업 관리)
- **반응형 디자인**: 모바일/태블릿/데스크톱 모두 지원
- **그래디언트 배경**: 직관적이고 현대적인 디자인
### 시각화
- **카드 기반 통계**: 4개 통계 카드 (색상 구분)
- OKPOS 상품별: 보라색 그래디언트
- OKPOS 영수증: 분홍색 그래디언트
- UPSOLUTION: 파란색 그래디언트
- 날씨: 초록색 그래디언트
- **인터랙티브 차트**: Chart.js 라이브러리
- 라인 차트로 방문객 추이 표시
- 호버 시 데이터 포인트 확대
- 반응형 크기 조정
### 사용자 경험
- **실시간 업데이트**: 대시보드 30초마다 자동 새로고침
- **즉시 피드백**: 데이터 로딩 상태 표시
- **접근성**: Bootstrap 및 Bootstrap Icons 활용
- **모바일 최적화**: 터치 친화적 인터페이스
---
## 📂 파일 구조
```
app/
├── app.py # Flask 애플리케이션 (추가된 대시보드 API)
├── file_processor.py # 파일 처리 로직
├── templates/
│ └── index.html # 완전히 개선된 대시보드 UI
└── static/
└── (CSS/JS 파일들)
```
### app.py 추가 사항
- `GET /api/dashboard/okpos-product`
- `GET /api/dashboard/okpos-receipt`
- `GET /api/dashboard/upsolution`
- `GET /api/dashboard/weather`
- `GET /api/dashboard/weekly-forecast`
- `GET /api/dashboard/visitor-trend`
### index.html 개선 사항
1. **대시보드 탭**
- 4개 통계 카드
- 주간 예보 테이블
- 방문객 추이 그래프
- 날짜 범위 선택 컨트롤
2. **파일 업로드 탭**
- 기존 드래그 앤 드롭 기능 유지
- 시스템 상태 표시
- 실시간 진행 표시
3. **백업 관리 탭**
- 백업 생성/복구 기능
---
## 🎨 디자인 특징
### 색상 스키마
- **Primary**: #0d6efd (파란색)
- **Success**: #198754 (녹색)
- **Danger**: #dc3545 (빨간색)
- **Info**: #0dcaf0 (하늘색)
- **Background**: 보라색 그래디언트 (667eea → 764ba2)
### 타이포그래피
- **폰트**: Segoe UI, Tahoma, Geneva, Verdana, Arial
- **크기 계층구조**: h1, h3, h5, body 텍스트
- **가중치**: 600 (중간 굵기), 700 (굵음)
### 간격
- **패딩**: 20px, 25px, 30px
- **마진**: 10px, 15px, 20px, 30px
- **갭**: 10px, 15px
---
## 🚀 배포 및 실행
### 요구 사항
- Python 3.11+
- Flask 3.0.0
- Chart.js (CDN)
- Bootstrap 5.3.0 (CDN)
### 실행 명령
```bash
cd /path/to/static
docker-compose up -d
# 웹 브라우저에서 접근
http://localhost:8889
```
### 포트 및 서비스
- **포트 8889**: Flask 웹 서버
- **포트 3306**: MariaDB 데이터베이스
- **포트 5000**: 기타 서비스 (선택)
---
## 📊 데이터 소스
### 데이터베이스 테이블
1. **pos** - OKPOS 상품별 데이터
2. **pos_billdata** - OKPOS 영수증 데이터
3. **pos_ups_billdata** - UPSOLUTION 데이터
4. **weather** - 기상청 날씨 데이터
5. **ga4_by_date** - GA4 방문자 데이터
### 쿼리 최적화
- `COUNT(DISTINCT date)`: 저장 일수 계산
- `MAX(date)`: 최종 저장일 확인
- `COUNT(*)`: 총 데이터 수 계산
- `AVG(users)`: 예상 방문객 계산
---
## ✅ 완성된 요구사항
✅ 메인(대시보드) 페이지 구현
✅ OKPOS 일자별 상품별 데이터 통계
✅ OKPOS 영수증데이터 통계
✅ UPSOLUTION 데이터 통계
✅ 날씨/기상정보 데이터 통계
✅ 이번 주 예상 날씨와 방문객 예상 표
✅ 방문객 추이 그래프 (날짜 조절 가능)
✅ 기본값: 최근 1개월
✅ 3개 탭 네비게이션
✅ Bootstrap 디자인
✅ 실시간 데이터 업데이트
✅ 반응형 레이아웃
---
## 🎯 사용자 흐름
1. **대시보드 접속**
- URL: http://localhost:8889
- 자동 통계 로드
- 30초마다 새로고침
2. **데이터 조회**
- 4개 카드에서 실시간 통계 확인
- 주간 예보 테이블에서 추이 파악
- 방문객 그래프로 트렌드 분석
3. **날짜 조절**
- 시작/종료 날짜 선택
- "조회" 버튼 클릭
- 그래프 자동 업데이트
4. **파일 관리** (필요 시)
- "파일 업로드" 탭 전환
- 파일 드래그 앤 드롭
- 자동 처리 및 DB 저장
5. **백업 관리** (필요 시)
- "백업 관리" 탭 전환
- 백업 생성/복구
---
**마지막 업데이트**: 2025년 12월 26일
**상태**: ✅ 완전 완성
모든 요구사항이 구현되었으며, 프로덕션 레벨의 웹 대시보드로 준비 완료입니다!

View File

@ -1,420 +0,0 @@
# 개발자 가이드 (Developer Guide)
이 문서는 First Garden Static Analysis Service에 기여하거나 개발하고자 하는 개발자를 위한 가이드입니다.
## 개발 환경 설정
### 1. 저장소 클론
```bash
git clone https://git.siane.kr/firstgarden/static.git
cd static
```
### 2. Python 가상환경 생성
```bash
# Python 3.11 이상 필수
python3.11 -m venv venv
# 가상환경 활성화
# Linux/macOS
source venv/bin/activate
# Windows
.\venv\Scripts\activate
```
### 3. 의존성 설치
```bash
pip install --upgrade pip setuptools wheel
pip install -r requirements.txt
# 개발 도구 추가 설치
pip install pytest pytest-cov black flake8 mypy
```
### 4. 환경 변수 설정
```bash
cp .env.example .env
# .env 파일을 편집하여 로컬 DB 정보 입력
```
### 5. 로컬 데이터베이스 설정
```bash
# Docker로 MariaDB 실행 (기존 DB가 없는 경우)
docker run --name fg-static-db \
-e MYSQL_ROOT_PASSWORD=rootpassword \
-e MYSQL_DATABASE=firstgarden \
-e MYSQL_USER=firstgarden \
-e MYSQL_PASSWORD=Fg9576861! \
-p 3306:3306 \
mariadb:11.2-jammy
```
---
## 코드 스타일
### PEP 8 준수
```bash
# 코드 포매팅
black lib/ conf/ daily_run.py
# 린트 검사
flake8 lib/ conf/ daily_run.py --max-line-length=100
# 타입 검사
mypy lib/ conf/ --ignore-missing-imports
```
### 명명 규칙
- 함수/변수: `snake_case`
- 클래스: `PascalCase`
- 상수: `UPPER_CASE`
- 비공개 함수: `_leading_underscore`
### 문서화
모든 함수에 docstring 작성:
```python
def fetch_data(start_date, end_date, **kwargs):
"""
데이터 조회 함수
Args:
start_date (datetime.date): 시작 날짜
end_date (datetime.date): 종료 날짜
**kwargs: 추가 매개변수
Returns:
pd.DataFrame: 조회된 데이터
Raises:
ValueError: 유효하지 않은 날짜 범위
DatabaseError: DB 연결 실패
"""
...
```
---
## 로깅 사용법
```python
from lib.common import setup_logging
# 로거 생성
logger = setup_logging('module_name', 'INFO')
# 로그 출력
logger.info('정보 메시지')
logger.warning('경고 메시지')
logger.error('에러 메시지', exc_info=True) # 스택 트레이스 포함
logger.debug('디버그 메시지')
```
---
## 데이터베이스 작업
### 세션 관리
```python
from conf.db import DBSession
# 권장: 컨텍스트 매니저 사용
with DBSession() as session:
result = session.execute(select(some_table))
# 자동으로 커밋 또는 롤백
# 또는 기존 방식
session = db.get_session()
try:
result = session.execute(select(some_table))
session.commit()
finally:
session.close()
```
### 쿼리 작성
```python
from sqlalchemy import select, and_, func
from conf import db_schema
# 데이터 조회
session = db.get_session()
stmt = select(
db_schema.weather.c.date,
db_schema.weather.c.maxTa
).where(
and_(
db_schema.weather.c.date >= '2025-01-01',
db_schema.weather.c.stnId == 99
)
)
result = session.execute(stmt).fetchall()
```
---
## API 데이터 수집
### 기본 패턴
```python
import requests
from lib.common import setup_logging, retry_on_exception
logger = setup_logging(__name__, 'INFO')
@retry_on_exception(max_retries=3, delay=1.0, backoff=2.0)
def fetch_api_data(url, params):
"""API 데이터 수집"""
try:
response = requests.get(url, params=params, timeout=20)
response.raise_for_status()
data = response.json()
logger.info(f"API 데이터 수집 완료: {len(data)} 건")
return data
except requests.exceptions.RequestException as e:
logger.error(f"API 요청 실패: {e}")
raise
```
---
## 테스트 작성
### 단위 테스트
```bash
# 테스트 디렉토리 생성
mkdir tests
# 테스트 파일 작성
cat > tests/test_common.py << 'EOF'
import pytest
from lib.common import load_config
def test_load_config():
config = load_config()
assert config is not None
assert 'database' in config
assert 'DATA_API' in config
def test_load_config_invalid_path():
with pytest.raises(FileNotFoundError):
load_config('/invalid/path.yaml')
EOF
# 테스트 실행
pytest tests/ -v
pytest tests/ --cov=lib --cov=conf
```
### 통합 테스트
```python
# tests/test_integration.py
import pytest
from conf.db import DBSession
from lib.weekly_visitor_forecast_prophet import load_data
def test_load_data_integration():
"""DB에서 데이터 로드 테스트"""
with DBSession() as session:
from datetime import date, timedelta
start_date = date.today() - timedelta(days=30)
end_date = date.today()
df = load_data(session, start_date, end_date)
assert len(df) > 0
assert 'pos_qty' in df.columns
```
---
## 모듈 개발 체크리스트
새로운 모듈을 추가할 때 다음을 확인하세요:
- [ ] 모든 함수에 docstring 작성
- [ ] PEP 8 코드 스타일 준수
- [ ] 로깅 추가 (info, warning, error)
- [ ] 에러 처리 구현
- [ ] 단위 테스트 작성
- [ ] `requirements.txt` 업데이트
- [ ] README.md 업데이트
- [ ] CHANGELOG.md 업데이트
---
## Git 워크플로우
### 기본 브랜치
- `main`: 배포 준비 브랜치
- `develop`: 개발 메인 브랜치
- `feature/*`: 기능 개발
- `bugfix/*`: 버그 수정
### 커밋 메시지 포맷
```
[타입] 간단한 설명
더 자세한 설명 (선택사항)
연관 이슈: #123
```
**타입:**
- `feat`: 새로운 기능
- `fix`: 버그 수정
- `docs`: 문서화
- `refactor`: 코드 리팩토링
- `test`: 테스트 추가
- `chore`: 설정 변경
### 브랜치 생성 및 병합
```bash
# 브랜치 생성
git checkout develop
git pull origin develop
git checkout -b feature/새로운기능
# 커밋
git add .
git commit -m "[feat] 새로운 기능 추가"
# 푸시
git push origin feature/새로운기능
# Merge Request/Pull Request 생성 후 코드 리뷰
```
---
## CI/CD 파이프라인
### GitHub Actions (예상 설정)
```yaml
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: pytest tests/ --cov=lib --cov=conf
```
---
## 문제 해결
### 일반적인 개발 문제
**Q: DB 연결 실패**
```bash
# DB 상태 확인
docker ps | grep mariadb
# DB 접속 확인
mysql -h localhost -u firstgarden -p firstgarden
# conf/config.yaml에서 DB 정보 확인
```
**Q: 패키지 설치 오류**
```bash
# 캐시 초기화
pip cache purge
# 의존성 재설치
pip install -r requirements.txt --force-reinstall
```
**Q: 포트 이미 사용 중**
```bash
# 기존 컨테이너 제거
docker-compose down -v
# 포트 사용 프로세스 확인
lsof -i :3306
```
---
## 성능 프로파일링
### 실행 시간 측정
```python
import time
from lib.common import setup_logging
logger = setup_logging(__name__)
start = time.time()
# 코드 실행
elapsed = time.time() - start
logger.info(f"실행 시간: {elapsed:.2f}초")
```
### 메모리 프로파일링
```bash
# memory_profiler 설치
pip install memory-profiler
# 스크립트 실행
python -m memory_profiler script.py
```
---
## 배포 준비
### 프로덕션 체크리스트
- [ ] 모든 테스트 통과
- [ ] 코드 리뷰 완료
- [ ] 버전 번호 업데이트 (CHANGELOG.md)
- [ ] 환경 변수 검증
- [ ] 데이터베이스 마이그레이션 확인
- [ ] Docker 이미지 빌드 및 테스트
- [ ] 보안 취약점 검사
- [ ] 성능 벤치마크
---
## 유용한 명령어
```bash
# 개발 모드로 실행
PYTHONUNBUFFERED=1 python daily_run.py
# 로그 모니터링
tail -f logs/daily_run.log
# Docker 컨테이너 로그
docker-compose logs -f fg-static
# 데이터베이스 접속
docker-compose exec mariadb mysql -u firstgarden -p firstgarden
# 파이썬 인터랙티브 셸
python -c "import sys; sys.path.insert(0, '.'); from conf import db; print(db.load_config())"
```
---
## 참고 자료
- [Python 공식 가이드](https://docs.python.org/)
- [SQLAlchemy 문서](https://docs.sqlalchemy.org/)
- [Prophet 가이드](https://facebook.github.io/prophet/docs/installation.html)
- [Docker 문서](https://docs.docker.com/)
- [Git 가이드](https://git-scm.com/doc)
---
**작성일**: 2025-12-26
**마지막 업데이트**: 2025-12-26

View File

@ -1,297 +0,0 @@
# POS 데이터 파일 업로드 웹 서버 - 구현 요약
## 📋 프로젝트 구조
```
static/
├── app/ # Flask 웹 애플리케이션
│ ├── __init__.py
│ ├── app.py # 메인 Flask 앱 (포트 8889)
│ ├── file_processor.py # 파일 처리 로직
│ ├── templates/
│ │ └── index.html # Bootstrap UI (드래그앤드롭, 실시간 모니터링)
│ └── static/ # CSS, JS 정적 파일
├── lib/
│ ├── pos_update_daily_product.py # OKPOS 파일 처리 (process_okpos_file())
│ ├── pos_update_upsolution.py # UPSOLUTION 파일 처리 (process_upsolution_file())
│ ├── common.py # setup_logging(), get_logger()
│ └── ... # 기타 데이터 수집 모듈
├── conf/
│ ├── db.py # DB 설정 (load_config, get_engine, get_session)
│ ├── db_schema.py # DB 스키마 (pos, pos_ups_billdata 등)
│ ├── config.yaml # 설정 파일
│ └── service-account-credentials.json
├── data/ # 데이터 디렉토리
│ ├── finish/ # 처리 완료 파일
│ └── cache/ # 캐시 데이터
├── uploads/ # 임시 파일 업로드 폴더 (웹 서버용)
├── dbbackup/ # 데이터베이스 백업 폴더 (Docker 마운트)
├── logs/ # 애플리케이션 로그
├── db_data/ # 데이터베이스 데이터 (Docker 마운트)
├── output/ # 출력 데이터
├── build/
│ └── Dockerfile # 컨테이너 이미지 정의
├── docker-compose.yml # 다중 컨테이너 오케스트레이션
├── requirements.txt # Python 의존성
├── .env # 환경 변수
├── daily_run.py # 일일 자동 실행 스크립트
└── README.md # 프로젝트 문서
```
## 🔧 핵심 컴포넌트
### 1. Flask 웹 서버 (app/app.py)
**포트:** 8889
**API 엔드포인트:**
- `GET /` - 메인 페이지
- `POST /api/upload` - 파일 업로드 처리
- `GET /api/status` - 시스템 상태 조회
- `POST /api/backup` - 데이터베이스 백업 생성
- `POST /api/restore` - 데이터베이스 복구
- `GET /api/backups` - 백업 목록 조회
**주요 기능:**
- 최대 100MB 파일 크기 지원
- 한글 JSON 응답 지원
- 에러 핸들러 (413, 500)
- 종합적인 로깅
### 2. 파일 처리 로직 (app/file_processor.py)
**클래스:** FileProcessor
**주요 메서드:**
- `get_file_type(filename)` - UPSOLUTION, OKPOS 파일 타입 감지
- `validate_file(filename)` - 파일 검증 (확장자, 패턴)
- `process_uploads(uploaded_files)` - 다중 파일 배치 처리
- `process_file(filepath, file_type)` - 개별 파일 처리
- `_process_okpos_file(filepath)` - OKPOS 파일 처리
- `_process_upsolution_file(filepath)` - UPSOLUTION 파일 처리
- `create_database_backup()` - mysqldump를 통한 백업 생성
- `restore_database_backup(filename)` - mysql을 통한 복구
- `list_database_backups()` - 백업 목록 조회
**파일 타입 감지 규칙:**
- UPSOLUTION: 파일명에 "UPSOLUTION" 포함
- OKPOS: "일자별" + "상품별" 또는 "영수증별매출상세현황" 포함
**지원 파일 형식:** .xlsx, .xls, .csv
### 3. 파일 처리 함수
#### OKPOS 처리 (lib/pos_update_daily_product.py)
```python
def process_okpos_file(filepath):
# 반환: {'success': bool, 'message': str, 'rows_inserted': int}
```
#### UPSOLUTION 처리 (lib/pos_update_upsolution.py)
```python
def process_upsolution_file(filepath):
# 반환: {'success': bool, 'message': str, 'rows_inserted': int}
```
### 4. 웹 사용자 인터페이스 (app/templates/index.html)
**디자인:** Bootstrap 5.3.0
**주요 기능:**
- 드래그 앤 드롭 파일 업로드
- 파일 목록 표시 및 제거
- 실시간 시스템 상태 모니터링
- 데이터베이스 연결 상태
- 업로드 폴더 상태
- 업로드된 파일 수
- 업로드 진행 상황 표시
- 데이터베이스 백업 생성
- 백업 목록 조회
- 백업 복구 기능
- 실시간 알림 (5초 자동 숨김)
**색상 테마:**
- 주색: #0d6efd (파란색)
- 성공: #198754 (녹색)
- 경고: #dc3545 (빨간색)
### 5. Docker 환경 설정
#### docker-compose.yml
**서비스:**
1. **mariadb** - MariaDB 11.2
- 포트: 3306
- 볼륨:
- `./db_data:/var/lib/mysql` - 데이터베이스 데이터
- `./dbbackup:/dbbackup` - 백업 폴더
- `./conf/install.sql:/docker-entrypoint-initdb.d/init.sql` - 초기화 스크립트
2. **fg-static** - Python 애플리케이션
- 포트: 8889 (Flask), 5000 (기타)
- 볼륨:
- `./uploads:/app/uploads` - 파일 업로드 폴더
- `./dbbackup:/app/dbbackup` - 백업 폴더
- `./data:/app/data` - 데이터 폴더
- `./logs:/app/logs` - 로그 폴더
- 헬스체크: `/api/status` 엔드포인트
#### Dockerfile
**베이스 이미지:** python:3.11-slim-bullseye
**설치 패키지:**
- gcc, g++, build-essential
- libmysqlclient-dev, libssl-dev, libffi-dev
- curl, wget, git, cron
- **mariadb-client** (mysqldump, mysql 도구)
**실행 서비스:**
1. Cron 데몬 - 일일 자동 실행
2. File Watch 서비스 - 로컬 파일 감시
3. Flask 웹 서버 - 포트 8889
### 6. 환경 변수 (.env)
```
DB_ROOT_PASSWORD=rootpassword
DB_NAME=firstgarden
DB_USER=firstgarden
DB_PASSWORD=Fg9576861!
DB_PORT=3306
DB_HOST=mariadb
TZ=Asia/Seoul
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE=1
LOG_LEVEL=INFO
FLASK_ENV=production
FLASK_DEBUG=0
```
## 📊 데이터 처리 흐름
```
웹 브라우저
드래그 앤 드롭 파일 선택
POST /api/upload (FormData)
Flask app.upload_files()
FileProcessor.process_uploads()
├─ 파일 검증 (확장자, 패턴)
├─ 파일 저장 (uploads/)
├─ FileProcessor.process_file()
│ ├─ OKPOS 감지 → _process_okpos_file()
│ │ → process_okpos_file() 호출
│ │ → pos 테이블 저장
│ │
│ └─ UPSOLUTION 감지 → _process_upsolution_file()
│ → process_upsolution_file() 호출
│ → pos_ups_billdata 테이블 저장
└─ 성공: 파일 삭제, 응답 반환
실패: 에러 로깅, 에러 응답 반환
JSON 응답 (성공/실패, 메시지, 행 수)
HTML UI 업데이트
```
## 🔄 백업/복구 흐름
### 백업 생성
```
웹 UI "백업 생성" 클릭
POST /api/backup
FileProcessor.create_database_backup()
mysqldump 실행
(hostname, user, password, database)
./dbbackup/backup_YYYYMMDD_HHMMSS.sql 생성
JSON 응답 (filename)
```
### 복구
```
웹 UI "복구" 버튼 클릭
POST /api/restore (filename)
FileProcessor.restore_database_backup()
mysql 실행 (backup_YYYYMMDD_HHMMSS.sql)
성공/실패 응답
```
## 🚀 시작 방법
### Docker Compose로 실행
```bash
# 이미지 빌드 및 서비스 시작
cd /path/to/static
docker-compose up -d
# 로그 확인
docker-compose logs -f fg-static
# 웹 접근
http://localhost:8889
```
### 필수 디렉토리
- `./uploads/` - 파일 업로드 (자동 생성)
- `./dbbackup/` - 백업 폴더 (자동 생성)
- `./logs/` - 로그 폴더 (자동 생성)
- `./db_data/` - DB 데이터 (자동 생성)
## 📝 로깅
**로그 위치:**
- Flask 앱: `/app/logs/flask_app.log`
- Cron: `/app/logs/cron.log`
- File Watch: `/app/logs/file_watch.log`
- 일일 실행: `/app/logs/daily_run.log`
**Docker 컨테이너 로그:**
```bash
docker-compose logs fg-static
```
## ✅ 검증 체크리스트
- [x] Flask 앱이 포트 8889에서 실행
- [x] 파일 업로드 endpoint 구현
- [x] OKPOS/UPSOLUTION 파일 타입 감지
- [x] 파일 검증 로직
- [x] DB 저장 함수 (process_okpos_file, process_upsolution_file)
- [x] 백업/복구 기능 (mysqldump/mysql)
- [x] Bootstrap UI (드래그앤드롭, 실시간 모니터링)
- [x] Docker 바인드 마운트 설정
- [x] 환경 변수 설정
- [x] 한글 주석 추가
- [x] 종합적인 로깅
## 🔌 의존성
**Python 패키지:**
- Flask==3.0.0
- SQLAlchemy==2.0.23
- pandas==2.1.3
- openpyxl==3.1.2
- xlrd==2.0.1
- Werkzeug (secure_filename)
**시스템 도구:**
- mysqldump (DB 백업)
- mysql (DB 복구)
**Docker 이미지:**
- python:3.11-slim-bullseye
- mariadb:11.2-jammy
---
**최종 업데이트:** 2025년 완성
**상태:** 프로덕션 준비 완료

View File

@ -1,399 +0,0 @@
# 📊 First Garden Static Analysis Service - 개선 완료 보고서
**프로젝트명**: First Garden 방문통계 분석 서비스
**개선 완료일**: 2025년 12월 26일
**개선 범위**: 코드 품질, Docker 컨테이너화, 문서화
---
## 🎯 개선 목표 달성도
| 목표 | 상태 | 설명 |
|------|------|------|
| ✅ 코드 품질 개선 | **완료** | 로깅, 에러 처리, 설정 관리 표준화 |
| ✅ 예측 정확도 개선 | **준비완료** | Prophet 모델 파라미터 최적화 (실시간 조정 가능) |
| ✅ Docker 컨테이너화 | **완료** | 모든 플랫폼 호환 및 자동 배포 준비 |
| ✅ 문서화 개선 | **완료** | README, DEVELOPMENT, CHANGELOG 작성 |
---
## 📝 세부 개선사항
### 1⃣ 코드 품질 개선
#### requirements.txt
- **변경**: 모든 패키지 버전 명시
- **효과**: 재현 가능한 빌드, 버전 호환성 보장
- **주요 패키지**:
- Python 3.11+ 호환
- Flask 3.0.0 (웹 프레임워크)
- SQLAlchemy 2.0.23 (ORM)
- Prophet 1.1.5 (시계열 예측)
- Pandas 2.1.3 (데이터 분석)
#### conf/db.py - 데이터베이스 연결 개선
**추가 기능**:
- 연결 풀 관리 (pool_size=10, max_overflow=20)
- 1시간 주기 자동 재연결
- 연결 전 핸들 확인 (pool_pre_ping)
- 컨텍스트 매니저 기반 세션 관리
```python
# 기존 (1줄)
engine = create_engine(db_url, pool_pre_ping=True, pool_recycle=3600)
# 개선 (100줄+)
engine = create_engine(
db_url,
poolclass=pool.QueuePool,
pool_pre_ping=True,
pool_recycle=3600,
pool_size=10,
max_overflow=20
)
```
#### lib/common.py - 로깅 및 유틸리티 강화
**추가 함수**:
- `setup_logging()`: 일관된 로깅 포맷
- `retry_on_exception()`: 자동 재시도 데코레이터
- `load_config()`: 에러 처리 개선
- `wait_download_complete()`: 향상된 파일 대기
```python
@retry_on_exception(max_retries=3, delay=1.0, backoff=2.0)
def fetch_api_data():
# 자동으로 3번까지 재시도 (지수 백오프)
...
```
#### daily_run.py - 엔터프라이즈급 로깅
```
[START] daily_run.py 시작: 2025-12-26 15:30:45
============================================================
[RUNNING] weather_asos.py
[SUCCESS] weather_asos 완료
[RUNNING] ga4.py
[SUCCESS] ga4 완료
[RUNNING] air_quality.py
[SUCCESS] air_quality 완료
[SUMMARY] 작업 완료 결과:
weather: ✓ SUCCESS
ga4: ✓ SUCCESS
air_quality: ✓ SUCCESS
[END] daily_run.py 종료: 2025-12-26 15:40:30
```
---
### 2⃣ 예측 정확도 개선 (준비 완료)
#### weekly_visitor_forecast_prophet.py 개선 방향
**계획된 기능**:
1. **특성 공학 (Feature Engineering)**
- Lag features (7일, 14일, 30일)
- Moving averages (7일, 14일)
- 요일(weekday) 원핫 인코딩
2. **Prophet 파라미터 최적화**
```python
Prophet(
yearly_seasonality=True,
weekly_seasonality=True,
daily_seasonality=False,
changepoint_prior_scale=0.05, # 변화점 감지
seasonality_prior_scale=10.0, # 계절성 강도
seasonality_mode='additive',
interval_width=0.95 # 신뢰 구간
)
```
3. **외부 변수 추가**
- minTa, maxTa: 기온
- sumRn: 강수량 (가중치 10.0)
- avgRhm: 습도
- pm25: 미세먼지
- is_holiday: 휴일 여부 (가중치 20)
---
### 3⃣ Docker 컨테이너화
#### Dockerfile 개선
**이전 (문제점)**:
- Python 3.10 → 3.11로 업그레이드
- GUI 라이브러리(tk) 포함 (불필요)
- 과도한 시스템 패키지
- tail 명령으로 로그 추적 (부정확함)
**개선 (현재)**:
- Python 3.11 slim 이미지 (300MB)
- GUI 라이브러리 제거
- 최소 필수 패키지만 설치
- 시그널 처리 및 프로세스 모니터링
- 헬스체크 스크립트 추가
#### docker-compose.yml 개선
**추가 기능**:
- ✅ MariaDB 11.2 서비스 통합
- ✅ 환경 변수 기반 설정
- ✅ 헬스체크 자동 실행
- ✅ 주기적 재시작 정책
- ✅ Named Volumes로 데이터 영속성
- ✅ 네트워크 격리
```yaml
mariadb:
image: mariadb:11.2-jammy
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
#### docker-entrypoint.sh (신규 추가)
**기능**:
- 색상 로깅 (INFO, WARN, ERROR)
- DB 연결 대기 (최대 30초)
- 크론 데몬 자동 시작
- file_watch.py 자동 시작
- 프로세스 모니터링 및 자동 재시작
- SIGTERM/SIGINT 신호 처리
---
### 4⃣ 문서화
#### README.md (신규 작성)
```
• 프로젝트 소개 및 주요 기능
• 사전 요구사항
• 설치 및 설정 가이드
• 3가지 실행 방법 (Docker, 로컬, 크론)
• 데이터 흐름 다이어그램
• 프로젝트 구조
• 주요 모듈 설명
• DB 스키마
• 문제 해결 가이드
• 성능 최적화 팁
```
#### CHANGELOG.md (신규 작성)
```
• 2025-12-26 개선사항 요약
• 추가/변경/제거/고정 사항
• 버전 정보
• 마이그레이션 가이드
• 알려진 문제
```
#### DEVELOPMENT.md (신규 작성)
```
• 개발 환경 설정
• 코드 스타일 가이드
• 로깅 사용법
• DB 작업 패턴
• API 데이터 수집 패턴
• 테스트 작성 방법
• Git 워크플로우
• CI/CD 설정 예제
• 문제 해결
• 성능 프로파일링
• 배포 체크리스트
```
#### 추가 파일
- ✅ .env.example: 환경 변수 템플릿
- ✅ .dockerignore: Docker 빌드 최적화
- ✅ .gitignore: Git 무시 규칙 개선
---
## 📊 개선 전/후 비교
| 항목 | 개선 전 | 개선 후 | 개선도 |
|------|--------|--------|--------|
| Dockerfile 크기 | ~500MB | ~300MB | ▼40% |
| 설정 관리 | 하드코딩 | 환경 변수 | 💯 |
| 로깅 표준화 | print() 혼용 | logging 통일 | 💯 |
| DB 재연결 | 수동 | 자동 (1시간) | 💯 |
| 에러 처리 | 기본 | try-catch 강화 | 💯 |
| 문서 페이지 | ~1 | ~4 (17KB) | ▲300% |
| 코드 주석 | 부분 | 전체 함수 | ▲100% |
---
## 🚀 사용 방법
### Docker Compose 실행 (권장)
```bash
# 저장소 클론
git clone https://git.siane.kr/firstgarden/static.git
cd static
# 환경 설정
cp .env.example .env
# .env 파일 편집 (DB 정보 입력)
# 서비스 시작
docker-compose up -d
# 로그 확인
docker-compose logs -f fg-static
# 서비스 중지
docker-compose down
```
### 로컬 실행
```bash
# 의존성 설치
pip install -r requirements.txt
# 데이터 수집 실행
python daily_run.py
# 예측 분석 실행
python -m lib.weekly_visitor_forecast_prophet
```
---
## 🔧 주요 설정값
### config.yaml
```yaml
database:
host: mariadb # Docker 환경
user: firstgarden
password: Fg9576861!
name: firstgarden
FORECAST_WEIGHT:
visitor_forecast_multiplier: 0.5
sumRn: 10.0 # 강수량 가중치 높음
is_holiday: 20 # 휴일 영향 큼
```
### 크론 설정
```
# 매일 11:00 UTC (서울 시간 20:00)에 자동 실행
0 11 * * * cd /app && python daily_run.py
```
---
## ⚠️ 알려진 제한사항
### 예측 모델
- ❌ 신규 데이터는 1년 이상 필요
- ❌ 특이값(이벤트) 자동 감지 미지원
- ❌ GPU 미지원 (CPU 기반만)
### API
- ❌ 병렬 처리 아직 부분 적용
- ❌ 대용량 데이터(1년 이상) 처리 최적화 필요
- ❌ API 속도 제한 관리 필요
---
## 🔮 향후 개선 계획
### Phase 1 (예정)
- [ ] REST API 엔드포인트 추가
- [ ] 웹 대시보드 (Flask/React)
- [ ] 실시간 업데이트 (WebSocket)
### Phase 2 (예정)
- [ ] 다중 모델 앙상블 (Prophet + ARIMA + RF)
- [ ] AutoML 파이프라인
- [ ] 설명 가능한 AI (SHAP)
- [ ] 이상 탐지 시스템
### Phase 3 (예정)
- [ ] 모바일 앱 연동
- [ ] GraphQL API
- [ ] 마이크로서비스 아키텍처
- [ ] 쿠버네티스 배포
---
## 📈 성능 지표
### 예상 성능
- **데이터 수집**: 5~10분 (매일)
- **모델 학습**: 3~5분 (주간)
- **메모리 사용**: 500MB~1GB
- **CPU 사용**: 10~30% (대기), 80~100% (학습 시)
### 확장성
- **동시 사용자**: 제한 없음 (읽기만)
- **데이터 보관**: 무제한 (MySQL 저장소 제한)
- **API 호출**: 데이터.고.kr 규정 준수
---
## 🔐 보안 개선사항
✅ 환경 변수 기반 설정 관리
✅ API 키 보안 강화
✅ .env 파일 .gitignore 추가
✅ 컨테이너 내 권한 제한
✅ 데이터베이스 암호 정책
✅ HTTPS 권장 (프로덕션)
---
## 📞 기술 지원
### 설치 문제
```bash
# DB 연결 확인
docker-compose logs mariadb
# 패키지 의존성 확인
pip check
```
### 성능 문제
```bash
# 로그 분석
tail -f logs/daily_run.log
# 메모리 사용량 확인
docker stats fg-static
```
### 버그 보고
[Gitea Issues](https://git.siane.kr/firstgarden/static/issues)에서 보고
---
## 📄 라이선스 & 기여
- **라이선스**: MIT License
- **저장소**: https://git.siane.kr/firstgarden/static
- **개발팀**: First Garden Team
---
## 마무리
이 개선 작업을 통해 First Garden Static Analysis Service는:
- 🎯 **안정성**: 에러 처리 및 로깅 강화
- 🚀 **확장성**: Docker 컨테이너화로 모든 플랫폼 지원
- 📚 **유지보수성**: 상세한 문서화
- 🔧 **운영성**: 자동화된 배포 및 모니터링
**현재 프로덕션 배포 준비 완료 상태입니다.**
---
**작성일**: 2025-12-26
**마지막 수정**: 2025-12-26

511
README.md
View File

@ -1,481 +1,50 @@
# First Garden 방문통계 분석 서비스 (Static Analysis Service) # 퍼스트가든 방문통계 간소화
## 종관기상관측정보 자동 업데이트
- `data.go.kr` 에서 종관기상관측 자료 API를 통한 자료 요청 및 업데이트.
- DB에 저장된 데이터로부터 어제자 데이터까지 수집
[![Python Version](https://img.shields.io/badge/Python-3.11+-blue.svg)](https://www.python.org/downloads/) ## 대기환경정보 자동 업데이트
[![Docker](https://img.shields.io/badge/Docker-Supported-blue.svg)](https://www.docker.com/) - `data.go.kr` 에서 에어코리아 API를 통한 자동 업데이트.
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) - DB에 저장된 가장 최근 날짜 + 1일 ~ 어제자 데이터까지 수집.
> 퍼스트가든 방문객 데이터 자동 수집, DB 저장, 시계열 예측 분석 서비스 ## GA4 업데이트
- 구글 애널리틱스 데이터를 업데이트함, 각 차원에 따라 업데이트
- 개발중
## 🚀 주요 기능 ## POS 데이터 업데이트
- POS사와의 계약이슈로 중단
### 1. 자동 데이터 수집 ## POS 데이터를 엑셀로 다운받은 후 자동 업로드
- **기상청 ASOS 데이터**: 일별 기온, 강수량, 습도 등 수집 - 파일 첨부와 해석, 업데이트 기능 생성 필요함
- **Google Analytics 4**: 웹 방문자 데이터 수집 및 분석
- **대기환경 정보**: 미세먼지(PM2.5) 등 대기질 데이터 수집
### 2. 웹 기반 파일 업로드 (포트 8889) ## 폴더 구조
- **드래그 앤 드롭 파일 업로드**: 직관적인 파일 선택 및 업로드
- **다중 파일 지원**: 여러 POS 데이터 파일 일괄 처리
- **파일 형식 지원**:
- **OKPOS**: 일자별 상품별, 영수증별매출상세현황 파일
- **UPSOLUTION**: POS 데이터 파일
- **실시간 상태 모니터링**: 업로드, 검증, DB 저장 진행 상황 확인
- **데이터베이스 백업/복구**: 웹 인터페이스에서 간편한 백업 관리
### 3. 데이터베이스 관리
- MariaDB/MySQL 기반 데이터 적재
- 자동 중복 제거 및 데이터 검증
- 스케줄 기반 자동 업데이트 (매일 11:00 UTC)
- 웹 인터페이스를 통한 백업 및 복구
### 4. 방문객 예측 분석
- **Prophet 시계열 모델**: 장기 추세 및 계절성 반영
- **다중 외부 변수**: 기상, 대기질, 휴일 정보 포함
- **신뢰도 구간**: 상한/하한 예측값 제공
### 5. 컨테이너화 지원
- Docker & Docker Compose 완전 지원
- 모든 플랫폼(Linux, macOS, Windows)에서 실행 가능
- 헬스체크 및 자동 재시작 기능
- 실제 볼륨 마운트 (바인드 마운트) 지원
---
## 📋 사전 요구사항
### 로컬 실행
- Python 3.11 이상
- MariaDB 10.4 이상 또는 MySQL 5.7 이상
- pip (Python 패키지 관리자)
### Docker 실행
- Docker 20.10 이상
- Docker Compose 1.29 이상
---
## ⚙️ 설치 및 설정
### 1. 저장소 클론
```bash ```bash
git clone https://git.siane.kr/firstgarden/static.git project-root/
cd static ├── app/ # 🔹 웹 프론트엔드 및 Flask 서버
``` │ ├── templates/ # HTML 템플릿 (Jinja2)
│ │ └── index.html
│ ├── static/ # (선택) JS, CSS 파일
│ └── app.py # Flask 애플리케이션 진입점
### 2. 환경 변수 설정 ├── build/ # 🔹 Docker 빌드 전용 디렉토리
```bash │ ├── Dockerfile # Ubuntu 22.04 기반 Dockerfile
# .env 파일 생성 (템플릿 복사) │ ├── requirements.txt # Python 의존성
cp .env.example .env │ └── (선택) run.sh / build.sh 등 실행 스크립트
# .env 파일 편집 ├── conf/ # 🔹 설정 및 DB 정의
nano .env │ ├── config.yaml # 설정 파일 (DB 접속 등)
```
**.env 주요 변수:**
```env
# Database Configuration
DB_HOST=localhost
DB_PORT=3306
DB_NAME=firstgarden
DB_USER=firstgarden
DB_PASSWORD=your_secure_password
# API Keys (필수)
# service-account-credentials.json 경로는 conf/ 디렉토리에 저장
# Timezone
TZ=Asia/Seoul
```
### 3. 설정 파일 준비
#### conf/config.yaml
```yaml
database:
host: localhost
user: firstgarden
password: your_password
name: firstgarden
DATA_API:
serviceKey: "your_data_go_kr_api_key"
air:
station_name:
- "운정"
weather:
stnIds:
- 99
ga4:
property_id: your_ga4_property_id
service_account_file: "./service-account-credentials.json"
POS:
VISITOR_CA:
- 입장료
- 티켓
- 기업제휴
FORECAST_WEIGHT:
visitor_forecast_multiplier: 0.5
minTa: 1.0
maxTa: 1.0
sumRn: 10.0
avgRhm: 1.0
pm25: 1.0
is_holiday: 20
```
#### GA4 서비스 계정
1. Google Cloud Console에서 서비스 계정 생성
2. JSON 키 다운로드
3. `conf/service-account-credentials.json`에 저장
---
## 🚀 실행 방법
### 방법 1: Docker Compose (권장)
```bash
# 이미지 빌드 및 서비스 시작
docker-compose up -d
# 로그 확인
docker-compose logs -f fg-static
# 서비스 상태 확인
docker-compose ps
# 서비스 중지
docker-compose down
```
#### 웹 기반 파일 업로드 서버 접근
```bash
# 웹 브라우저에서 접근
http://localhost:8889
# 또는 Docker 컨테이너 내부에서
# http://fg-static-app:8889
```
**웹 인터페이스 기능:**
- 📁 드래그 앤 드롭 파일 업로드
- 📊 실시간 업로드 진행 상황 모니터링
- 💾 데이터베이스 백업 생성
- 🔄 백업 파일로부터 데이터베이스 복구
- ✅ 파일 검증 및 자동 처리
**API 엔드포인트:**
- `POST /api/upload` - 파일 업로드 처리
- `GET /api/status` - 시스템 상태 조회
- `POST /api/backup` - 데이터베이스 백업 생성
- `POST /api/restore` - 데이터베이스 복구
- `GET /api/backups` - 백업 목록 조회
**대시보드 API:**
- `GET /api/dashboard/okpos-product` - OKPOS 상품별 통계
- `GET /api/dashboard/okpos-receipt` - OKPOS 영수증 통계
- `GET /api/dashboard/upsolution` - UPSOLUTION 통계
- `GET /api/dashboard/weather` - 날씨 데이터 통계
- `GET /api/dashboard/weekly-forecast` - 주간 예보 및 방문객 예상
- `GET /api/dashboard/visitor-trend` - 방문객 추이 (날짜 범위 조절 가능)
### 방법 2: 로컬 Python 실행
```bash
# 의존성 설치
pip install -r requirements.txt
# 데이터 수집 스크립트 실행
python daily_run.py
# 방문객 예측 실행
python -m lib.weekly_visitor_forecast_prophet
```
### 방법 3: 크론 작업으로 자동 실행
```bash
# crontab 편집
crontab -e
# 매일 11:00에 실행하도록 추가
0 11 * * * cd /path/to/static && python daily_run.py >> logs/daily_run.log 2>&1
```
---
## 📊 데이터 흐름
```
┌─────────────────────────────────────────────────────────┐
│ 외부 API 데이터 수집 │
├──────────────────────────────────────────────────────────┤
│ • 기상청 ASOS (weather_asos.py) │
│ • 에어코리아 (air_quality.py) │
│ • Google Analytics 4 (ga4.py) │
│ • POS 시스템 (pos_update_*.py) │
└─────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 데이터 저장 (MariaDB) │
├──────────────────────────────────────────────────────────┤
│ • fg_manager_static_weather (날씨 데이터) │
│ • fg_manager_static_air (대기질 데이터) │
│ • fg_manager_static_ga4_by_date (방문자 데이터) │
│ • fg_manager_static_pos (POS 데이터) │
└─────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 시계열 분석 및 예측 │
├──────────────────────────────────────────────────────────┤
│ • 특성 공학 (lag features, moving averages) │
│ • Prophet 모델 훈련 │
│ • 미래 7일 방문객 예측 │
│ • 예측 결과 CSV 저장 (data/prophet_result.csv) │
└─────────────────────────────────────────────────────────┘
```
---
## 📁 프로젝트 구조
```
static/
├── conf/ # 설정 및 DB 정의
│ ├── config.yaml # 애플리케이션 설정 (동적)
│ ├── config.sample.yaml # 설정 샘플
│ ├── db.py # SQLAlchemy 연결 설정 │ ├── db.py # SQLAlchemy 연결 설정
── db_schema.py # DB 테이블 정의 ── db_schema.py # 테이블 정의 (SQLAlchemy metadata)
│ └── service-account-credentials.json # GA4 인증 (보안)
├── lib/ # 🔹 데이터 처리 및 백엔드 로직
├── lib/ # 데이터 처리 및 분석 모듈 │ ├── pos_view_gui.py # 기존 Tkinter GUI (조회용)
│ ├── common.py # 공통 함수 (로깅, 설정 로드) │ ├── pos_update_gui.py # 기존 Tkinter GUI (업데이트용)
│ ├── weather_asos.py # 기상청 데이터 수집 │ ├── air_quality.py # 대기환경 API 수집
│ ├── weather_forecast.py # 날씨 예보 조회 │ ├── ga4.py # GA4 수집 스크립트
── air_quality.py # 대기환경 데이터 수집 ── weather_asos.py # 기상청 ASOS 수집
│ ├── ga4.py # Google Analytics 수집
│ ├── holiday.py # 한국 휴일 관리 ├── data/ # 🔹 데이터 저장 및 엑셀 업로드 디렉토리
── weekly_visitor_forecast_prophet.py # 방문객 예측 (Prophet) ── (엑셀 파일들, 일자별 상품별 파일 등)
│ ├── weekly_visitor_forecast.py # 방문객 분석
│ ├── pos_update_*.py # POS 데이터 업데이트 └── .gitignore (선택)
│ ├── file_watch.py # 파일 변경 감시
│ └── requests_utils.py # HTTP 요청 유틸
├── data/ # 데이터 저장소
│ ├── prophet_result.csv # 예측 결과
│ ├── sample.csv # 샘플 데이터
│ └── cache/ # API 캐시
├── output/ # 출력 파일
│ └── [분석 결과 저장]
├── build/ # Docker 빌드
│ └── Dockerfile # Docker 이미지 정의
├── daily_run.py # 일일 작업 실행 스크립트
├── docker-compose.yml # Docker Compose 설정
├── requirements.txt # Python 의존성
├── .env.example # 환경 변수 템플릿
├── .gitignore # Git 무시 파일
└── README.md # 본 문서
``` ```
---
## 🔧 주요 모듈 설명
### daily_run.py
매일 정기적으로 실행되는 메인 스크립트
```bash
# 실행 명령
python daily_run.py
# 실행 순서
1. weather_asos.py - 기상 데이터 수집
2. ga4.py - Google Analytics 수집
3. air_quality.py - 대기질 데이터 수집
```
### lib/weekly_visitor_forecast_prophet.py
Prophet을 이용한 고급 시계열 예측
**특징:**
- 1년 이상의 과거 데이터 학습
- Lag features & Moving averages 포함
- 기상, 대기질, 휴일 변수 통합
- 신뢰도 구간(95%) 제공
**실행:**
```bash
python -m lib.weekly_visitor_forecast_prophet
```
**출력:**
- `data/prophet_result.csv` - 7일 예측 결과
- 예측값, 하한, 상한 포함
### lib/common.py
공통 유틸리티 함수
```python
from lib.common import setup_logging, load_config, retry_on_exception
# 로거 설정
logger = setup_logging('my_module', 'INFO')
# 설정 로드
config = load_config()
# 재시도 데코레이터
@retry_on_exception(max_retries=3, delay=1.0, backoff=2.0)
def api_call():
...
```
---
## 📊 데이터베이스 스키마
### 주요 테이블
#### fg_manager_static_ga4_by_date
| 컬럼명 | 타입 | 설명 |
|--------|------|------|
| date | DATE | 기준 날짜 (PK) |
| activeUsers | INT | 활성 사용자 수 |
| screenPageViews | INT | 페이지뷰 |
| sessions | INT | 세션 수 |
#### fg_manager_static_weather
| 컬럼명 | 타입 | 설명 |
|--------|------|------|
| date | DATE | 기준 날짜 (PK) |
| stnId | INT | 관측소 ID (PK) |
| minTa | FLOAT | 최저 기온 (℃) |
| maxTa | FLOAT | 최고 기온 (℃) |
| sumRn | FLOAT | 강수량 (mm) |
| avgRhm | FLOAT | 평균 습도 (%) |
#### fg_manager_static_air
| 컬럼명 | 타입 | 설명 |
|--------|------|------|
| date | DATE | 기준 날짜 (PK) |
| station | VARCHAR | 측정소 이름 (PK) |
| pm25 | FLOAT | PM2.5 농도 (㎍/㎥) |
| pm10 | FLOAT | PM10 농도 (㎍/㎥) |
---
## 🐛 문제 해결
### Docker 실행 시 DB 연결 오류
```bash
# DB 컨테이너가 준비될 때까지 대기
docker-compose ps # 모든 서비스 상태 확인
# 로그 확인
docker-compose logs mariadb
```
### API 요청 실패
- API Key 및 권한 확인
- 네트워크 연결 상태 확인
- API 할당량 확인
### 데이터 동기화 오류
```bash
# 강제 재동기화
python daily_run.py
# 특정 데이터만 재수집
python -c "from lib.weather_asos import main; main()"
```
---
## 🚀 성능 최적화
### 데이터베이스
- 연결 풀링 활성화 (pool_size=10, max_overflow=20)
- 1시간마다 자동 재연결
- 인덱스 기반 쿼리 최적화
### API 요청
- 병렬 처리 (ThreadPoolExecutor)
- 재시도 메커니즘 (exponential backoff)
- 캐싱 (로컬 JSON 캐시)
### 모델 학습
- 충분한 메모리 할당 권장 (최소 2GB)
- GPU 미지원 (CPU 기반)
---
## 📈 개선 계획
- [ ] 실시간 대시보드 (Flask/React)
- [ ] REST API 엔드포인트
- [ ] 다중 예측 모델 앙상블
- [ ] 설명 가능한 AI (SHAP)
- [ ] 모바일 앱 연동
- [ ] 이상 탐지 (Anomaly Detection)
- [ ] AutoML 파이프라인
---
## 🔐 보안
### 민감한 정보
- `.env` 파일은 절대 커밋하지 않기
- API 키는 환경 변수로 관리
- `service-account-credentials.json` 보호
- 프로덕션 환경에서 강한 DB 비밀번호 사용
### 권장 사항
- HTTPS 통신 사용
- 네트워크 방화벽 설정
- 정기적인 백업
- 로그 모니터링
---
## 📝 라이선스
MIT License - [LICENSE](LICENSE) 파일 참조
---
## 👥 기여자
**개발**: First Garden Team
---
## 📞 지원 및 문의
**저장소**: [https://git.siane.kr/firstgarden/static](https://git.siane.kr/firstgarden/static)
**문제 보고**: Gitea Issues 탭에서 등록
**요청사항**: Discussions 탭에서 논의
---
## 📚 참고 자료
- [Prophet 공식 문서](https://facebook.github.io/prophet/)
- [SQLAlchemy 튜토리얼](https://docs.sqlalchemy.org/)
- [Google Analytics API](https://developers.google.com/analytics/devguides/reporting/data/v1)
- [데이터.고.kr API](https://www.data.go.kr/)
- [Docker 공식 문서](https://docs.docker.com/)
---
**마지막 업데이트**: 2025년 12월 26일

View File

@ -1,53 +0,0 @@
# 퍼스트가든 방문통계 간소화
## 종관기상관측정보 자동 업데이트
- `data.go.kr` 에서 종관기상관측 자료 API를 통한 자료 요청 및 업데이트.
- DB에 저장된 데이터로부터 어제자 데이터까지 수집
## 대기환경정보 자동 업데이트
- `data.go.kr` 에서 에어코리아 API를 통한 자동 업데이트.
- DB에 저장된 가장 최근 날짜 + 1일 ~ 어제자 데이터까지 수집.
## GA4 업데이트
- 구글 애널리틱스 데이터를 업데이트함, 각 차원에 따라 업데이트
- 개발중
## POS 데이터 업데이트
- POS사와의 계약이슈로 중단
## POS 데이터를 엑셀로 다운받은 후 자동 업로드
- 파일 첨부와 해석, 업데이트 기능 생성 필요함
## 폴더 구조
```bash
project-root/
├── app/ # 🔹 웹 프론트엔드 및 Flask 서버
│ ├── templates/ # HTML 템플릿 (Jinja2)
│ │ └── index.html
│ ├── static/ # (선택) JS, CSS 파일
│ └── app.py # Flask 애플리케이션 진입점
├── build/ # 🔹 Docker 빌드 전용 디렉토리
│ ├── Dockerfile # Ubuntu 22.04 기반 Dockerfile
│ ├── requirements.txt # Python 의존성
│ └── (선택) run.sh / build.sh 등 실행 스크립트
├── conf/ # 🔹 설정 및 DB 정의
│ ├── config.yaml # 설정 파일 (DB 접속 등)
│ ├── db.py # SQLAlchemy 연결 설정
│ └── db_schema.py # 테이블 정의 (SQLAlchemy metadata)
├── lib/ # 🔹 데이터 처리 및 백엔드 로직
│ ├── common.py # 중복 함수들을 처리
│ ├── pos_view_gui.py # 기존 Tkinter GUI (조회용)
│ ├── pos_update_gui.py # 기존 Tkinter GUI (업데이트용)
│ ├── air_quality.py # 대기환경 API 수집
│ ├── ga4.py # GA4 수집 스크립트
│ ├── weather_asos.py # 기상청 ASOS 수집
│ ├── weekly_visitor_forecast.py # GA4 수집 스크립트
│ ├── weekly_visitor_forecast_prophet.py # GA4 수집 스크립트
│ └──
├── data/ # 🔹 데이터 저장 및 엑셀 업로드 디렉토리
│ └── (엑셀 파일들, 일자별 상품별 파일 등)
├── .gitignore
└── README.md
```

View File

@ -1,2 +0,0 @@
# app/__init__.py
"""Flask 애플리케이션 패키지"""

View File

@ -1,69 +1,131 @@
# app.py import os, sys
""" from flask import Flask, render_template, request, jsonify
POS 데이터 웹 애플리케이션 from sqlalchemy import select, func, between, and_, or_
from datetime import datetime, timedelta
import json
기능: # 경로 추가
- 파일 업로드 및 처리 sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
- 대시보드 통계 및 예측 from conf import db, db_schema
- 데이터베이스 백업/복구
"""
import os app = Flask(__name__)
import sys engine = db.engine
import logging pos_table = db_schema.pos
from flask import Flask @app.route('/')
def index():
today = datetime.today().date()
end_date = today - timedelta(days=1)
start_date = end_date - timedelta(days=6)
return render_template('index.html', start_date=start_date, end_date=end_date)
# 프로젝트 루트 경로 추가 @app.route('/api/ca01_list')
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) def ca01_list():
with engine.connect() as conn:
result = conn.execute(
select(pos_table.c.ca01).distinct().order_by(pos_table.c.ca01)
).scalars().all()
return jsonify(['전체'] + result)
from lib.common import setup_logging @app.route('/api/ca03_list')
from app.blueprints import dashboard_bp, upload_bp, backup_bp, status_bp def ca03_list():
ca01 = request.args.get('ca01', None)
with engine.connect() as conn:
query = select(pos_table.c.ca03).distinct().order_by(pos_table.c.ca03)
if ca01 and ca01 != '전체':
query = query.where(pos_table.c.ca01 == ca01)
result = conn.execute(query).scalars().all()
return jsonify(['전체'] + result)
# 로거 설정 @app.route('/search', methods=['GET'])
logger = setup_logging('pos_web_app', 'INFO') def search():
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
ca01 = request.args.get('ca01')
ca03 = request.args.get('ca03')
conditions = [between(pos_table.c.date, start_date, end_date)]
if ca01 and ca01 != '전체':
conditions.append(pos_table.c.ca01 == ca01)
if ca03 and ca03 != '전체':
conditions.append(pos_table.c.ca03 == ca03)
def create_app(): with engine.connect() as conn:
"""Flask 애플리케이션 팩토리""" stmt = select(
pos_table.c.ca01,
pos_table.c.ca02,
pos_table.c.ca03,
pos_table.c.name,
func.sum(pos_table.c.qty).label("qty"),
func.sum(pos_table.c.tot_amount).label("tot_amount"),
func.sum(pos_table.c.tot_discount).label("tot_discount"),
func.sum(pos_table.c.actual_amount).label("actual_amount")
).where(*conditions).group_by(pos_table.c.barcode)
# Flask 앱 초기화 result = conn.execute(stmt).mappings().all()
app = Flask(__name__, template_folder='templates', static_folder='static') return jsonify([dict(row) for row in result])
# 설정 # 월별 데이터 불러오기
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(__file__), '..', 'uploads') def get_monthly_visitor_data(ca01_keywords=None, ca03_includes=None):
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB 최대 파일 크기 from collections import defaultdict
app.config['JSON_AS_ASCII'] = False # 한글 JSON 지원 from decimal import Decimal
# 업로드 폴더 생성 ca01_keywords = ca01_keywords or ['매표소']
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) ca03_includes = ca03_includes or ['입장료', '티켓', '기업제휴']
# Blueprint 등록 pos = db_schema.pos
app.register_blueprint(dashboard_bp) session = db.get_session()
app.register_blueprint(upload_bp)
app.register_blueprint(backup_bp)
app.register_blueprint(status_bp)
# 에러 핸들러 # 필터 조건
@app.errorhandler(413) ca01_conditions = [pos.c.ca01.like(f'%{kw}%') for kw in ca01_keywords]
def handle_large_file(e): conditions = [or_(*ca01_conditions), pos.c.ca03.in_(ca03_includes)]
"""파일 크기 초과"""
return {'error': '파일이 너무 큽니다 (최대 100MB)'}, 413
@app.errorhandler(500) # 연도별 월별 합계 쿼리
def handle_internal_error(e): query = (
"""내부 서버 오류""" session.query(
logger.error(f"Internal server error: {e}") func.year(pos.c.date).label('year'),
return {'error': '서버 오류가 발생했습니다'}, 500 func.month(pos.c.date).label('month'),
func.sum(pos.c.qty).label('qty')
)
.filter(and_(*conditions))
.group_by(func.year(pos.c.date), func.month(pos.c.date))
.order_by(func.year(pos.c.date), func.month(pos.c.date))
)
result = query.all()
session.close()
def run_app(host='0.0.0.0', port=8889, debug=False): # 결과 가공: {년도: [1~12월 값]} 형태
"""애플리케이션 실행""" data = defaultdict(lambda: [0]*12)
app = create_app()
logger.info(f"애플리케이션 시작: {host}:{port}")
app.run(host=host, port=port, debug=debug)
for row in result:
year = int(row.year)
month = int(row.month)
qty = int(row.qty or 0) if isinstance(row.qty, Decimal) else row.qty or 0
data[year][month - 1] = qty
# Dict → 일반 dict 정렬
return dict(sorted(data.items()))
@app.route('/monthly_view.html')
def monthly_view():
visitor_data = get_monthly_visitor_data()
visitor_data_json = json.dumps(visitor_data) # JSON 문자열로 변환
return render_template('monthly_view.html', visitor_data=visitor_data_json)
from lib.weekly_visitor_forecast_prophet import get_forecast_dict
from lib.weekly_visitor_forecast import get_recent_dataframe, get_last_year_dataframe
@app.route('/2weeks_view')
def view_2weeks():
df_recent = get_recent_dataframe()
df_prev = get_last_year_dataframe()
return render_template(
'2weeks_view.html',
recent_data=df_recent.to_dict(orient='records'),
lastyear_data=df_prev.to_dict(orient='records')
)
if __name__ == '__main__': if __name__ == '__main__':
run_app() app.run(debug=True, host='0.0.0.0')

View File

@ -1,13 +0,0 @@
# app/blueprints/__init__.py
"""
Flask Blueprints 모듈
각 기능별 Blueprint를 정의합니다.
"""
from .dashboard import dashboard_bp
from .upload import upload_bp
from .backup import backup_bp
from .status import status_bp
__all__ = ['dashboard_bp', 'upload_bp', 'backup_bp', 'status_bp']

View File

@ -1,129 +0,0 @@
# app/blueprints/backup.py
"""
백업 관리 블루프린트
역할:
- 데이터베이스 백업 생성
- 백업 복구
- 백업 목록 조회
"""
from flask import Blueprint, render_template, request, jsonify
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from app.file_processor import FileProcessor
backup_bp = Blueprint('backup', __name__, url_prefix='/api')
def get_file_processor(upload_folder):
"""파일 프로세서 인스턴스 반환"""
return FileProcessor(upload_folder)
@backup_bp.route('/backup-page')
def index():
"""백업 관리 페이지"""
return render_template('backup.html')
@backup_bp.route('/backup', methods=['POST'])
def create_backup():
"""
새 백업 생성
응답:
{
'success': bool,
'message': str,
'filename': str (선택사항)
}
"""
try:
backup_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'backups')
os.makedirs(backup_folder, exist_ok=True)
file_processor = get_file_processor(backup_folder)
backup_info = file_processor.create_database_backup()
return jsonify({
'success': True,
'message': '백업이 생성되었습니다.',
'filename': backup_info.get('filename')
})
except Exception as e:
return jsonify({
'success': False,
'message': f'백업 생성 실패: {str(e)}'
}), 500
@backup_bp.route('/backups', methods=['GET'])
def get_backups():
"""
백업 목록 조회
응답:
{
'backups': List[dict] - [{'filename': str, 'size': int, 'created': str}, ...]
}
"""
try:
backup_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'backups')
os.makedirs(backup_folder, exist_ok=True)
file_processor = get_file_processor(backup_folder)
backups = file_processor.list_database_backups()
return jsonify({'backups': backups})
except Exception as e:
return jsonify({
'error': str(e)
}), 500
@backup_bp.route('/restore', methods=['POST'])
def restore_backup():
"""
백업 복구
요청:
{
'filename': str - 복구할 백업 파일명
}
응답:
{
'success': bool,
'message': str
}
"""
try:
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({
'success': False,
'message': '파일명을 지정하세요.'
}), 400
backup_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'backups')
file_processor = get_file_processor(backup_folder)
file_processor.restore_database_backup(filename)
return jsonify({
'success': True,
'message': '데이터베이스가 복구되었습니다.'
})
except Exception as e:
return jsonify({
'success': False,
'message': f'복구 실패: {str(e)}'
}), 500

View File

@ -1,225 +0,0 @@
# app/blueprints/dashboard.py
"""
대시보드 블루프린트
역할:
- 데이터 통계 조회 (OKPOS, UPSolution, 날씨)
- 주간 예보 조회
- 방문객 추이 차트 데이터 제공
"""
from flask import Blueprint, render_template, jsonify, request
from datetime import datetime, timedelta
from sqlalchemy import select, func, desc
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from conf import db, db_schema
dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/api/dashboard')
@dashboard_bp.route('/')
def index():
"""대시보드 페이지"""
return render_template('dashboard.html')
@dashboard_bp.route('/okpos-product', methods=['GET'])
def get_okpos_product_stats():
"""OKPOS 상품별 통계"""
try:
session = db.get_session()
# 데이터 개수
total_records = session.query(db_schema.OkposProduct).count()
# 데이터 보유 일수
earliest = session.query(func.min(db_schema.OkposProduct.data_date)).scalar()
latest = session.query(func.max(db_schema.OkposProduct.data_date)).scalar()
if earliest and latest:
total_days = (latest - earliest).days + 1
last_date = latest.strftime('%Y-%m-%d')
else:
total_days = 0
last_date = None
session.close()
return jsonify({
'total_records': total_records,
'total_days': total_days,
'last_date': last_date
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/okpos-receipt', methods=['GET'])
def get_okpos_receipt_stats():
"""OKPOS 영수증 통계"""
try:
session = db.get_session()
total_records = session.query(db_schema.OkposReceipt).count()
earliest = session.query(func.min(db_schema.OkposReceipt.receipt_date)).scalar()
latest = session.query(func.max(db_schema.OkposReceipt.receipt_date)).scalar()
if earliest and latest:
total_days = (latest - earliest).days + 1
last_date = latest.strftime('%Y-%m-%d')
else:
total_days = 0
last_date = None
session.close()
return jsonify({
'total_records': total_records,
'total_days': total_days,
'last_date': last_date
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/upsolution', methods=['GET'])
def get_upsolution_stats():
"""UPSolution 통계"""
try:
session = db.get_session()
total_records = session.query(db_schema.Upsolution).count()
earliest = session.query(func.min(db_schema.Upsolution.sales_date)).scalar()
latest = session.query(func.max(db_schema.Upsolution.sales_date)).scalar()
if earliest and latest:
total_days = (latest - earliest).days + 1
last_date = latest.strftime('%Y-%m-%d')
else:
total_days = 0
last_date = None
session.close()
return jsonify({
'total_records': total_records,
'total_days': total_days,
'last_date': last_date
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/weather', methods=['GET'])
def get_weather_stats():
"""날씨 데이터 통계"""
try:
session = db.get_session()
total_records = session.query(db_schema.Weather).count()
earliest = session.query(func.min(db_schema.Weather.date)).scalar()
latest = session.query(func.max(db_schema.Weather.date)).scalar()
if earliest and latest:
total_days = (latest - earliest).days + 1
last_date = latest.strftime('%Y-%m-%d')
else:
total_days = 0
last_date = None
session.close()
return jsonify({
'total_records': total_records,
'total_days': total_days,
'last_date': last_date
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/weekly-forecast', methods=['GET'])
def get_weekly_forecast():
"""이번주 예상 날씨 & 방문객"""
try:
session = db.get_session()
# 오늘부터 7일간 데이터 조회
today = datetime.now().date()
end_date = today + timedelta(days=6)
forecast_data = []
for i in range(7):
current_date = today + timedelta(days=i)
day_name = ['', '', '', '', '', '', ''][current_date.weekday()]
# 날씨 데이터
weather = session.query(db_schema.Weather).filter(
db_schema.Weather.date == current_date
).first()
# 예상 방문객 (동일한 요일의 평균)
same_day_visitors = session.query(
func.avg(db_schema.DailyVisitor.visitors)
).filter(
func.dayofweek(db_schema.DailyVisitor.visit_date) == (current_date.weekday() + 2) % 7 + 1
).scalar()
forecast_data.append({
'date': current_date.strftime('%Y-%m-%d'),
'day': day_name,
'min_temp': weather.min_temp if weather else None,
'max_temp': weather.max_temp if weather else None,
'precipitation': weather.precipitation if weather else 0.0,
'humidity': weather.humidity if weather else None,
'expected_visitors': int(same_day_visitors) if same_day_visitors else 0
})
session.close()
return jsonify({'forecast_data': forecast_data})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/visitor-trend', methods=['GET'])
def get_visitor_trend():
"""방문객 추이 데이터"""
try:
start_date_str = request.args.get('start_date')
end_date_str = request.args.get('end_date')
if not start_date_str or not end_date_str:
return jsonify({'error': '날짜 범위를 지정하세요.'}), 400
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
session = db.get_session()
visitors = session.query(
db_schema.DailyVisitor.visit_date,
db_schema.DailyVisitor.visitors
).filter(
db_schema.DailyVisitor.visit_date.between(start_date, end_date)
).order_by(db_schema.DailyVisitor.visit_date).all()
session.close()
dates = [v[0].strftime('%Y-%m-%d') for v in visitors]
visitor_counts = [v[1] for v in visitors]
return jsonify({
'dates': dates,
'visitors': visitor_counts
})
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@ -1,57 +0,0 @@
# app/blueprints/status.py
"""
시스템 상태 블루프린트
역할:
- 데이터베이스 연결 상태 확인
- 업로드 폴더 상태 확인
"""
from flask import Blueprint, jsonify
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from conf import db
status_bp = Blueprint('status', __name__, url_prefix='/api')
@status_bp.route('/status', methods=['GET'])
def get_status():
"""
시스템 상태 확인
응답:
{
'database': bool - 데이터베이스 연결 여부,
'upload_folder': bool - 업로드 폴더 접근 여부
}
"""
try:
# 데이터베이스 연결 확인
database_ok = False
try:
session = db.get_session()
session.execute('SELECT 1')
session.close()
database_ok = True
except Exception as e:
print(f"Database connection error: {e}")
# 업로드 폴더 확인
upload_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'uploads')
upload_folder_ok = os.path.isdir(upload_folder) and os.access(upload_folder, os.W_OK)
return jsonify({
'database': database_ok,
'upload_folder': upload_folder_ok
})
except Exception as e:
return jsonify({
'error': str(e),
'database': False,
'upload_folder': False
}), 500

View File

@ -1,83 +0,0 @@
# app/blueprints/upload.py
"""
파일 업로드 블루프린트
역할:
- 파일 업로드 처리
- 업로드 UI 제공
"""
from flask import Blueprint, render_template, request, jsonify
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from app.file_processor import FileProcessor
upload_bp = Blueprint('upload', __name__, url_prefix='/api/upload')
def get_file_processor(upload_folder):
"""파일 프로세서 인스턴스 반환"""
return FileProcessor(upload_folder)
@upload_bp.route('/')
def index():
"""파일 업로드 페이지"""
return render_template('upload.html')
@upload_bp.route('', methods=['POST'])
def upload_files():
"""
파일 업로드 처리
요청:
files: MultiDict[FileStorage] - 업로드된 파일들
응답:
{
'success': bool,
'message': str,
'files': List[dict],
'errors': List[dict]
}
"""
try:
if 'files' not in request.files:
return jsonify({
'success': False,
'message': '업로드된 파일이 없습니다.',
'files': [],
'errors': []
}), 400
uploaded_files = request.files.getlist('files')
if len(uploaded_files) == 0:
return jsonify({
'success': False,
'message': '파일을 선택하세요.',
'files': [],
'errors': []
}), 400
# 업로드 폴더 경로
upload_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'uploads')
os.makedirs(upload_folder, exist_ok=True)
# 파일 처리
file_processor = get_file_processor(upload_folder)
results = file_processor.process_uploads(uploaded_files)
return jsonify(results)
except Exception as e:
return jsonify({
'success': False,
'message': f'업로드 처리 중 오류: {str(e)}',
'files': [],
'errors': [str(e)]
}), 500

View File

@ -1,477 +0,0 @@
# 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)

View File

@ -1,2 +0,0 @@
# app/static/.gitkeep
# Flask 정적 파일 디렉토리

View File

@ -1,75 +0,0 @@
/* ===== 백업 관리 전용 스타일 ===== */
/* 백업 아이템 */
.backup-item {
background: white;
border: 1px solid #dee2e6;
padding: 15px;
border-radius: 8px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.backup-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.backup-info {
flex: 1;
}
.backup-filename {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.backup-size {
font-size: 12px;
color: #666;
margin-top: 5px;
}
.backup-actions {
display: flex;
gap: 10px;
}
.backup-actions button {
border-radius: 8px;
padding: 6px 12px;
font-size: 12px;
}
/* 백업 목록 */
#backup-list {
margin-top: 20px;
}
/* 빈 백업 메시지 */
.alert-info {
text-align: center;
}
/* 반응형 */
@media (max-width: 768px) {
.backup-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.backup-actions {
width: 100%;
flex-direction: column;
}
.backup-actions button {
width: 100%;
}
}

View File

@ -1,189 +0,0 @@
/* ===== CSS 변수 및 기본 스타일 ===== */
:root {
--primary-color: #0d6efd;
--success-color: #198754;
--danger-color: #dc3545;
--warning-color: #ffc107;
--info-color: #0dcaf0;
}
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.container-main {
max-width: 1400px;
margin: 0 auto;
}
/* ===== 헤더 ===== */
.header {
background: white;
padding: 30px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header h1 {
color: var(--primary-color);
font-weight: 700;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 15px;
}
.header p {
color: #666;
margin: 0;
font-size: 14px;
}
/* ===== 탭 네비게이션 ===== */
.nav-tabs {
background: white;
border: none;
padding: 0 20px;
border-radius: 12px 12px 0 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.nav-tabs .nav-link {
color: #666;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
font-weight: 500;
margin-right: 10px;
text-decoration: none;
}
.nav-tabs .nav-link:hover {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.nav-tabs .nav-link.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: transparent;
}
/* ===== 탭 콘텐츠 ===== */
.tab-content {
background: white;
border-radius: 0 0 12px 12px;
padding: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* ===== 버튼 스타일 ===== */
.btn-custom {
border-radius: 8px;
font-weight: 500;
padding: 10px 20px;
transition: all 0.3s ease;
}
.btn-primary.btn-custom {
background: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary.btn-custom:hover {
background: #0b5ed7;
border-color: #0b5ed7;
transform: translateY(-2px);
}
/* ===== 테이블 스타일 ===== */
.table {
margin: 0;
}
.table thead th {
background: #f8f9fa;
border-top: 2px solid #dee2e6;
font-weight: 600;
color: #333;
padding: 15px;
}
.table tbody td {
padding: 12px 15px;
vertical-align: middle;
}
.table tbody tr:hover {
background: #f8f9fa;
}
/* ===== 알림 스타일 ===== */
.alert {
border-radius: 8px;
border: none;
}
.alert-info {
background: #cfe2ff;
color: #084298;
}
.alert-success {
background: #d1e7dd;
color: #0f5132;
}
.alert-danger {
background: #f8d7da;
color: #842029;
}
.alert-warning {
background: #fff3cd;
color: #664d03;
}
/* ===== 반응형 ===== */
@media (max-width: 768px) {
.header {
padding: 20px;
}
.header h1 {
font-size: 20px;
}
.tab-content {
padding: 15px;
}
.nav-tabs {
padding: 0 10px;
}
.nav-tabs .nav-link {
font-size: 12px;
margin-right: 5px;
}
}
/* ===== 애니메이션 ===== */
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.alert {
animation: slideIn 0.3s ease;
}

View File

@ -1,101 +0,0 @@
/* ===== 대시보드 전용 스타일 ===== */
/* 통계 카드 */
.stat-card {
color: white;
padding: 25px;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
transition: transform 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-card.okpos-product {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stat-card.okpos-receipt {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stat-card.upsolution {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-card.weather {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.stat-card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 15px;
opacity: 0.9;
}
.stat-card .stat-value {
font-size: 32px;
font-weight: 700;
margin-bottom: 10px;
}
.stat-card .stat-label {
font-size: 12px;
opacity: 0.8;
}
.stat-card .stat-date {
font-size: 11px;
margin-top: 10px;
opacity: 0.7;
}
/* 차트 컨테이너 */
.chart-container {
position: relative;
height: 400px;
margin-bottom: 30px;
}
/* 날짜 피커 */
.date-range-picker {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.date-range-picker input {
border-radius: 8px;
border: 1px solid #dee2e6;
padding: 8px 12px;
}
.date-range-picker button {
border-radius: 8px;
padding: 8px 20px;
}
/* 반응형 */
@media (max-width: 768px) {
.stat-card {
margin-bottom: 15px;
}
.stat-card .stat-value {
font-size: 24px;
}
.date-range-picker {
flex-direction: column;
}
.chart-container {
height: 300px;
}
}

View File

@ -1,86 +0,0 @@
/* ===== 파일 업로드 전용 스타일 ===== */
/* 드롭존 */
.drop-zone {
border: 3px dashed var(--primary-color);
border-radius: 10px;
padding: 40px;
text-align: center;
cursor: pointer;
background: #f8f9fa;
transition: all 0.3s ease;
}
.drop-zone:hover {
background: #e7f1ff;
border-color: #0b5ed7;
}
.drop-zone.dragover {
background: #cfe2ff;
border-color: #0b5ed7;
transform: scale(1.02);
}
/* 파일 아이템 */
.file-item {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.file-item .file-name {
font-weight: 500;
color: #333;
}
.file-item-remove {
cursor: pointer;
color: var(--danger-color);
transition: all 0.3s ease;
}
.file-item-remove:hover {
color: darkred;
font-size: 20px;
}
/* 파일 목록 */
.file-list {
margin: 20px 0;
}
/* 업로드 진행바 */
.progress {
border-radius: 8px;
overflow: hidden;
}
.progress-bar {
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
/* 반응형 */
@media (max-width: 768px) {
.drop-zone {
padding: 20px;
}
.drop-zone i {
font-size: 32px !important;
}
.file-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}

View File

@ -1,87 +0,0 @@
/* ===== 백업 관리 JavaScript ===== */
/**
* 새로운 백업을 생성합니다.
*/
async function createBackup() {
try {
const data = await apiCall('/api/backup', { method: 'POST' });
if (data.success) {
showAlert('백업이 생성되었습니다.', 'success');
loadBackupList();
} else {
showAlert('백업 생성 실패: ' + data.message, 'danger');
}
} catch (error) {
showAlert('백업 생성 오류: ' + error.message, 'danger');
}
}
/**
* 백업 목록을 로드합니다.
*/
async function loadBackupList() {
try {
const data = await apiCall('/api/backups');
let html = '';
if (data.backups.length === 0) {
html = '<div class="alert alert-info">생성된 백업이 없습니다.</div>';
} else {
data.backups.forEach(backup => {
const sizeInMB = formatFileSize(backup.size);
html += `
<div class="backup-item">
<div class="backup-info">
<div class="backup-filename">
<i class="bi bi-file-earmark-zip"></i> ${backup.filename}
</div>
<div class="backup-size">
크기: ${sizeInMB}MB | 생성: ${backup.created}
</div>
</div>
<div class="backup-actions">
<button class="btn btn-sm btn-primary" onclick="restoreBackup('${backup.filename}')">
<i class="bi bi-arrow-counterclockwise"></i> 복구
</button>
</div>
</div>
`;
});
}
document.getElementById('backup-list').innerHTML = html;
} catch (error) {
console.error('백업 목록 로드 실패:', error);
}
}
/**
* 백업을 복구합니다.
* @param {string} filename - 복구할 백업 파일명
*/
function restoreBackup(filename) {
if (confirm(`${filename}에서 데이터베이스를 복구하시겠습니까?`)) {
fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
})
.then(r => r.json())
.then(result => {
if (result.success) {
showAlert('데이터베이스가 복구되었습니다.', 'success');
loadBackupList();
// 대시보드 새로고침
if (typeof loadDashboard === 'function') {
loadDashboard();
}
} else {
showAlert('복구 실패: ' + result.message, 'danger');
}
})
.catch(e => showAlert('복구 오류: ' + e.message, 'danger'));
}
}

View File

@ -1,84 +0,0 @@
/* ===== 공통 JavaScript 함수 ===== */
/**
* API를 호출하고 JSON 응답을 반환합니다.
* @param {string} url - API URL
* @param {object} options - fetch 옵션 (선택사항)
* @returns {Promise}
*/
async function apiCall(url, options = {}) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`API 오류: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API 호출 실패:', error);
throw error;
}
}
/**
* 숫자를 천 단위 구분 문자열로 변환합니다.
* @param {number} num - 변환할 숫자
* @returns {string}
*/
function formatNumber(num) {
return num.toLocaleString('ko-KR');
}
/**
* 파일 크기를 MB 단위로 변환합니다.
* @param {number} bytes - 바이트 크기
* @returns {string}
*/
function formatFileSize(bytes) {
return (bytes / 1024 / 1024).toFixed(2);
}
/**
* 알림 메시지를 표시합니다.
* @param {string} message - 표시할 메시지
* @param {string} type - 알림 타입 (info, success, warning, danger)
*/
function showAlert(message, type = 'info') {
const alertContainer = document.getElementById('alert-container');
if (!alertContainer) return;
const alertId = 'alert-' + Date.now();
const alertHtml = `
<div id="${alertId}" class="alert alert-${type} alert-dismissible fade show" role="alert" style="animation: slideIn 0.3s ease;">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
alertContainer.insertAdjacentHTML('beforeend', alertHtml);
// 5초 후 자동 제거
setTimeout(() => {
const alertElement = document.getElementById(alertId);
if (alertElement) alertElement.remove();
}, 5000);
}
/**
* 날짜 객체를 YYYY-MM-DD 형식의 문자열로 변환합니다.
* @param {Date} date - 변환할 날짜 객체
* @returns {string}
*/
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 날짜 문자열을 Date 객체로 변환합니다.
* @param {string} dateString - YYYY-MM-DD 형식의 날짜 문자열
* @returns {Date}
*/
function parseDate(dateString) {
return new Date(dateString + 'T00:00:00');
}

View File

@ -1,192 +0,0 @@
/* ===== 대시보드 JavaScript ===== */
let visitorTrendChart = null;
/**
* 대시보드를 초기화합니다.
*/
function initializeDashboard() {
initializeDatePickers();
loadDashboard();
// 30초마다 대시보드 새로고침
setInterval(loadDashboard, 30000);
}
/**
* 날짜 피커를 초기화합니다 (기본값: 최근 1개월).
*/
function initializeDatePickers() {
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
const startDateInput = document.getElementById('trend-start-date');
const endDateInput = document.getElementById('trend-end-date');
if (startDateInput) startDateInput.valueAsDate = thirtyDaysAgo;
if (endDateInput) endDateInput.valueAsDate = today;
}
/**
* 모든 대시보드 데이터를 로드합니다.
*/
async function loadDashboard() {
await Promise.all([
loadOKPOSProductStats(),
loadOKPOSReceiptStats(),
loadUPSolutionStats(),
loadWeatherStats(),
loadWeeklyForecast(),
loadVisitorTrend()
]);
}
/**
* OKPOS 상품별 통계를 로드합니다.
*/
async function loadOKPOSProductStats() {
try {
const data = await apiCall('/api/dashboard/okpos-product');
document.getElementById('okpos-product-count').textContent = formatNumber(data.total_records);
document.getElementById('okpos-product-days').textContent = `${data.total_days}`;
document.getElementById('okpos-product-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (error) {
console.error('OKPOS 상품별 통계 로드 실패:', error);
}
}
/**
* OKPOS 영수증 통계를 로드합니다.
*/
async function loadOKPOSReceiptStats() {
try {
const data = await apiCall('/api/dashboard/okpos-receipt');
document.getElementById('okpos-receipt-count').textContent = formatNumber(data.total_records);
document.getElementById('okpos-receipt-days').textContent = `${data.total_days}`;
document.getElementById('okpos-receipt-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (error) {
console.error('OKPOS 영수증 통계 로드 실패:', error);
}
}
/**
* UPSolution 통계를 로드합니다.
*/
async function loadUPSolutionStats() {
try {
const data = await apiCall('/api/dashboard/upsolution');
document.getElementById('upsolution-count').textContent = formatNumber(data.total_records);
document.getElementById('upsolution-days').textContent = `${data.total_days}`;
document.getElementById('upsolution-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (error) {
console.error('UPSolution 통계 로드 실패:', error);
}
}
/**
* 날씨 데이터 통계를 로드합니다.
*/
async function loadWeatherStats() {
try {
const data = await apiCall('/api/dashboard/weather');
document.getElementById('weather-count').textContent = formatNumber(data.total_records);
document.getElementById('weather-days').textContent = `${data.total_days}`;
document.getElementById('weather-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (error) {
console.error('날씨 통계 로드 실패:', error);
}
}
/**
* 주간 예보를 로드합니다.
*/
async function loadWeeklyForecast() {
try {
const data = await apiCall('/api/dashboard/weekly-forecast');
let html = '';
data.forecast_data.forEach(day => {
html += `
<tr>
<td><strong>${day.date} (${day.day})</strong></td>
<td>${day.min_temp !== null ? day.min_temp + '°C' : '-'}</td>
<td>${day.max_temp !== null ? day.max_temp + '°C' : '-'}</td>
<td>${day.precipitation.toFixed(1)}mm</td>
<td>${day.humidity !== null ? day.humidity + '%' : '-'}</td>
<td><strong>${formatNumber(day.expected_visitors)}명</strong></td>
</tr>
`;
});
document.getElementById('weekly-forecast-table').innerHTML = html;
} catch (error) {
console.error('주간 예보 로드 실패:', error);
}
}
/**
* 방문객 추이를 로드하고 그래프를 업데이트합니다.
*/
async function loadVisitorTrend() {
try {
const startDate = document.getElementById('trend-start-date').value;
const endDate = document.getElementById('trend-end-date').value;
if (!startDate || !endDate) {
console.log('날짜 범위가 설정되지 않았습니다.');
return;
}
const data = await apiCall(`/api/dashboard/visitor-trend?start_date=${startDate}&end_date=${endDate}`);
const ctx = document.getElementById('visitor-trend-chart');
// 기존 차트 제거
if (visitorTrendChart) {
visitorTrendChart.destroy();
}
// 새 차트 생성
visitorTrendChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.dates,
datasets: [{
label: '방문객',
data: data.visitors,
borderColor: 'var(--primary-color)',
backgroundColor: 'rgba(13, 110, 253, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: 'var(--primary-color)',
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top'
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
} catch (error) {
console.error('방문객 추이 로드 실패:', error);
}
}
/**
* 날짜 범위를 기본값(최근 1개월)으로 리셋하고 그래프를 새로고침합니다.
*/
function resetTrendDate() {
initializeDatePickers();
loadVisitorTrend();
}

View File

@ -1,187 +0,0 @@
/* ===== 파일 업로드 JavaScript ===== */
const FILE_LIST = [];
/**
* 파일 업로드 UI를 초기화합니다.
*/
function initializeUploadUI() {
setupDropZone();
setupFileButton();
checkSystemStatus();
}
/**
* 드롭존을 설정합니다.
*/
function setupDropZone() {
const dropZone = document.getElementById('drop-zone');
if (!dropZone) return;
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
}
/**
* 파일 선택 버튼을 설정합니다.
*/
function setupFileButton() {
const fileSelectBtn = document.getElementById('file-select-btn');
if (!fileSelectBtn) return;
fileSelectBtn.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '.xlsx,.xls,.csv';
input.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
input.click();
});
}
/**
* 파일들을 처리합니다.
* @param {FileList} files - 선택된 파일들
*/
function handleFiles(files) {
for (let file of files) {
FILE_LIST.push(file);
}
updateFileList();
}
/**
* 파일 목록을 화면에 업데이트합니다.
*/
function updateFileList() {
const fileListDiv = document.getElementById('file-list');
let html = '';
FILE_LIST.forEach((file, index) => {
html += `
<div class="file-item">
<div>
<div class="file-name">
<i class="bi bi-file-earmark"></i> ${file.name}
</div>
<small style="color: #999;">${formatFileSize(file.size)} MB</small>
</div>
<i class="bi bi-x-circle file-item-remove" onclick="removeFile(${index})"></i>
</div>
`;
});
fileListDiv.innerHTML = html;
}
/**
* 파일을 목록에서 제거합니다.
* @param {number} index - 제거할 파일의 인덱스
*/
function removeFile(index) {
FILE_LIST.splice(index, 1);
updateFileList();
}
/**
* 파일 목록을 비웁니다.
*/
function clearFileList() {
FILE_LIST.length = 0;
updateFileList();
document.getElementById('upload-result').innerHTML = '';
}
/**
* 파일들을 업로드합니다.
*/
async function uploadFiles() {
if (FILE_LIST.length === 0) {
showAlert('업로드할 파일을 선택하세요.', 'warning');
return;
}
const formData = new FormData();
FILE_LIST.forEach(file => {
formData.append('files', file);
});
document.getElementById('upload-progress').style.display = 'block';
document.getElementById('upload-btn').disabled = true;
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
let resultHtml = data.success ?
'<div class="alert alert-success">' :
'<div class="alert alert-warning">';
resultHtml += '<strong>업로드 완료!</strong><br>';
data.files.forEach(file => {
const icon = file.status === 'success' ? '✓' : '✗';
resultHtml += `${icon} ${file.filename}: ${file.message}<br>`;
});
resultHtml += '</div>';
document.getElementById('upload-result').innerHTML = resultHtml;
showAlert('업로드가 완료되었습니다.', 'success');
setTimeout(() => {
clearFileList();
// 대시보드 새로고침
if (typeof loadDashboard === 'function') {
loadDashboard();
}
}, 2000);
} catch (error) {
showAlert('업로드 실패: ' + error.message, 'danger');
} finally {
document.getElementById('upload-progress').style.display = 'none';
document.getElementById('upload-btn').disabled = false;
}
}
/**
* 시스템 상태를 확인합니다.
*/
async function checkSystemStatus() {
try {
const data = await apiCall('/api/status');
const dbStatus = document.getElementById('db-status');
const uploadStatus = document.getElementById('upload-folder-status');
if (dbStatus) {
dbStatus.textContent = data.database ? '연결됨' : '연결 안됨';
dbStatus.className = `badge ${data.database ? 'bg-success' : 'bg-danger'}`;
}
if (uploadStatus) {
uploadStatus.textContent = data.upload_folder ? '정상' : '오류';
uploadStatus.className = `badge ${data.upload_folder ? 'bg-success' : 'bg-danger'}`;
}
} catch (error) {
console.error('시스템 상태 확인 실패:', error);
}
}

61
app/templates/2weeks.html Normal file
View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>월별 입장객 현황</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body class="p-4">
<section class="2weeks_visitor_detail">
<h2>직전 2주간 방문객 현황 상세 내역</h2>
<table class="table table-bordered text-center align-middle">
<thead>
<tr>
<th colspan="2">구분</th>
<th colspan="{{ dates|length }}">방문현황</th>
<th rowspan="2">합계/평균</th>
<th colspan="3">예상</th>
</tr>
<tr>
<th>년도</th>
<th>항목</th>
{% for d in dates %}
<th>{{ d.strftime('%-m/%-d') }}</th>
{% endfor %}
<th>1일</th>
<th>2일</th>
<th>3일</th>
</tr>
</thead>
<tbody>
{% for year_label, data_by_item in data.items() %}
{% set rowspan_val = data_by_item|length %}
{% for item_name, row in data_by_item.items() %}
<tr>
{% if loop.first %}
<th rowspan="{{ rowspan_val }}">{{ year_label }}</th>
{% endif %}
<th>{{ item_name }}</th>
{% for val in row.values_list %}
<td>{{ val }}</td>
{% endfor %}
<td>{{ row.total or '' }}</td>
{% if row.expected %}
{% for ex_val in row.expected %}
<td>{{ ex_val }}</td>
{% endfor %}
{% else %}
<td colspan="3"></td>
{% endif %}
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</section>
</body>
</html>

View File

@ -1,33 +0,0 @@
{% extends "base.html" %}
{% block title %}백업 관리 - First Garden POS{% endblock %}
{% block extra_css %}
<link href="{{ url_for('static', filename='css/backup.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="tab-pane fade show active" id="backup-panel" role="tabpanel">
<div style="display: flex; gap: 10px; margin-bottom: 20px;">
<button class="btn btn-success btn-custom" onclick="createBackup()">
<i class="bi bi-plus-circle"></i> 새 백업 생성
</button>
<button class="btn btn-info btn-custom" onclick="loadBackupList()">
<i class="bi bi-arrow-clockwise"></i> 새로고침
</button>
</div>
<!-- 백업 목록 -->
<div id="backup-list"></div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/backup.js') }}"></script>
<script>
// 백업 관리 UI 초기화
document.addEventListener('DOMContentLoaded', function() {
loadBackupList();
});
</script>
{% endblock %}

View File

@ -1,72 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}First Garden - POS 데이터 관리{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- 공통 CSS -->
<link href="{{ url_for('static', filename='css/common.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="container-main">
<!-- 헤더 -->
<div class="header">
<h1>
<i class="bi bi-graph-up"></i>
First Garden POS 데이터 관리 시스템
</h1>
<p>실시간 데이터 모니터링, 파일 관리, 백업 시스템</p>
</div>
<!-- 탭 네비게이션 -->
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link {% if request.endpoint == 'dashboard.index' or not request.endpoint %}active{% endif %}"
href="{{ url_for('dashboard.index') }}" role="tab">
<i class="bi bi-speedometer2"></i> 대시보드
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if request.endpoint == 'upload.index' %}active{% endif %}"
href="{{ url_for('upload.index') }}" role="tab">
<i class="bi bi-cloud-upload"></i> 파일 업로드
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if request.endpoint == 'backup.index' %}active{% endif %}"
href="{{ url_for('backup.index') }}" role="tab">
<i class="bi bi-cloud-check"></i> 백업 관리
</a>
</li>
</ul>
<!-- 탭 콘텐츠 -->
<div class="tab-content">
{% block content %}{% endblock %}
</div>
</div>
<!-- 알림 영역 -->
<div id="alert-container" style="position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 400px;"></div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- 공통 JavaScript -->
<script src="{{ url_for('static', filename='js/common.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -1,108 +0,0 @@
{% extends "base.html" %}
{% block title %}대시보드 - First Garden POS{% endblock %}
{% block extra_css %}
<link href="{{ url_for('static', filename='css/dashboard.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="tab-pane fade show active" id="dashboard-panel" role="tabpanel">
<!-- 통계 카드 -->
<div class="row">
<div class="col-md-6 col-lg-3">
<div class="stat-card okpos-product">
<h3>OKPOS 상품별</h3>
<div class="stat-value" id="okpos-product-count">-</div>
<div class="stat-label">총 데이터</div>
<div class="stat-label" id="okpos-product-days">-</div>
<div class="stat-date" id="okpos-product-date">최종: -</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="stat-card okpos-receipt">
<h3>OKPOS 영수증</h3>
<div class="stat-value" id="okpos-receipt-count">-</div>
<div class="stat-label">총 데이터</div>
<div class="stat-label" id="okpos-receipt-days">-</div>
<div class="stat-date" id="okpos-receipt-date">최종: -</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="stat-card upsolution">
<h3>UPSolution</h3>
<div class="stat-value" id="upsolution-count">-</div>
<div class="stat-label">총 데이터</div>
<div class="stat-label" id="upsolution-days">-</div>
<div class="stat-date" id="upsolution-date">최종: -</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="stat-card weather">
<h3>날씨 데이터</h3>
<div class="stat-value" id="weather-count">-</div>
<div class="stat-label">총 데이터</div>
<div class="stat-label" id="weather-days">-</div>
<div class="stat-date" id="weather-date">최종: -</div>
</div>
</div>
</div>
<!-- 주간 예보 테이블 -->
<div style="margin-top: 30px;">
<h5 style="margin-bottom: 20px; font-weight: 600; color: #333;">
<i class="bi bi-calendar-event"></i> 이번주 예상 날씨 & 방문객
</h5>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>날짜</th>
<th>최저기온</th>
<th>최고기온</th>
<th>강수량</th>
<th>습도</th>
<th>예상 방문객</th>
</tr>
</thead>
<tbody id="weekly-forecast-table">
<tr>
<td colspan="6" class="text-center text-muted">데이터 로딩 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 방문객 추이 그래프 -->
<div style="margin-top: 30px;">
<h5 style="margin-bottom: 20px; font-weight: 600; color: #333;">
<i class="bi bi-graph-up"></i> 방문객 추이
</h5>
<!-- 날짜 범위 선택 -->
<div class="date-range-picker">
<input type="date" id="trend-start-date" class="form-control" style="max-width: 150px;">
<span style="display: flex; align-items: center;">~</span>
<input type="date" id="trend-end-date" class="form-control" style="max-width: 150px;">
<button class="btn btn-primary btn-sm" onclick="loadVisitorTrend()">조회</button>
<button class="btn btn-outline-primary btn-sm" onclick="resetTrendDate()">최근 1개월</button>
</div>
<!-- 그래프 -->
<div class="chart-container">
<canvas id="visitor-trend-chart"></canvas>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
<script>
// 대시보드 초기화
document.addEventListener('DOMContentLoaded', function() {
initializeDashboard();
});
</script>
{% endblock %}

View File

@ -1,867 +1,145 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>POS 데이터 조회</title>
<title>First Garden - POS 데이터 대시보드</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--primary-color: #0d6efd;
--success-color: #198754;
--danger-color: #dc3545;
--warning-color: #ffc107;
--info-color: #0dcaf0;
}
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.container-main {
max-width: 1400px;
margin: 0 auto;
}
/* 헤더 */
.header {
background: white;
padding: 30px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header h1 {
color: var(--primary-color);
font-weight: 700;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 15px;
}
.header p {
color: #666;
margin: 0;
font-size: 14px;
}
/* 탭 네비게이션 */
.nav-tabs {
background: white;
border: none;
padding: 0 20px;
border-radius: 12px 12px 0 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.nav-tabs .nav-link {
color: #666;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
font-weight: 500;
margin-right: 10px;
}
.nav-tabs .nav-link:hover {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.nav-tabs .nav-link.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: transparent;
}
/* 탭 콘텐츠 */
.tab-content {
background: white;
border-radius: 0 0 12px 12px;
padding: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 카드 스타일 */
.stat-card {
color: white;
padding: 25px;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
transition: transform 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-card.okpos-product {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stat-card.okpos-receipt {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stat-card.upsolution {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-card.weather {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.stat-card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 15px;
opacity: 0.9;
}
.stat-card .stat-value {
font-size: 32px;
font-weight: 700;
margin-bottom: 10px;
}
.stat-card .stat-label {
font-size: 12px;
opacity: 0.8;
}
.stat-card .stat-date {
font-size: 11px;
margin-top: 10px;
opacity: 0.7;
}
/* 테이블 스타일 */
.table {
margin: 0;
}
.table thead th {
background: #f8f9fa;
border-top: 2px solid #dee2e6;
font-weight: 600;
color: #333;
padding: 15px;
}
.table tbody td {
padding: 12px 15px;
vertical-align: middle;
}
.table tbody tr:hover {
background: #f8f9fa;
}
/* 버튼 스타일 */
.btn-custom {
border-radius: 8px;
font-weight: 500;
padding: 10px 20px;
transition: all 0.3s ease;
}
.btn-primary.btn-custom {
background: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary.btn-custom:hover {
background: #0b5ed7;
border-color: #0b5ed7;
transform: translateY(-2px);
}
/* 드롭존 */
.drop-zone {
border: 3px dashed var(--primary-color);
border-radius: 10px;
padding: 40px;
text-align: center;
cursor: pointer;
background: #f8f9fa;
transition: all 0.3s ease;
}
.drop-zone:hover {
background: #e7f1ff;
border-color: #0b5ed7;
}
.drop-zone.dragover {
background: #cfe2ff;
border-color: #0b5ed7;
transform: scale(1.02);
}
/* 차트 컨테이너 */
.chart-container {
position: relative;
height: 400px;
margin-bottom: 30px;
}
/* 날짜 피커 */
.date-range-picker {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.date-range-picker input {
border-radius: 8px;
border: 1px solid #dee2e6;
padding: 8px 12px;
}
.date-range-picker button {
border-radius: 8px;
padding: 8px 20px;
}
/* 파일 아이템 */
.file-item {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.file-item .file-name {
font-weight: 500;
color: #333;
}
/* 백업 아이템 */
.backup-item {
background: white;
border: 1px solid #dee2e6;
padding: 15px;
border-radius: 8px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.backup-info {
flex: 1;
}
.backup-filename {
font-weight: 600;
color: #333;
}
.backup-size {
font-size: 12px;
color: #666;
margin-top: 5px;
}
/* 반응형 */
@media (max-width: 768px) {
.stat-card {
margin-bottom: 15px;
}
.stat-card .stat-value {
font-size: 24px;
}
.date-range-picker {
flex-direction: column;
}
.tab-content {
padding: 15px;
}
}
</style>
</head> </head>
<body> <body class="p-4">
<div class="container-main"> <h2>POS 데이터 조회</h2>
<!-- 헤더 -->
<div class="header">
<h1>
<i class="bi bi-graph-up"></i>
First Garden POS 데이터 대시보드
</h1>
<p>실시간 데이터 모니터링 및 파일 관리 시스템</p>
</div>
<!-- 탭 네비게이션 --> <form id="filterForm" class="row g-3">
<ul class="nav nav-tabs" role="tablist"> <div class="col-md-3">
<li class="nav-item" role="presentation"> <label>시작일</label>
<button class="nav-link active" id="dashboard-tab" data-bs-toggle="tab" data-bs-target="#dashboard-panel" type="button" role="tab"> <input type="date" name="start_date" value="{{ start_date }}" class="form-control">
<i class="bi bi-speedometer2"></i> 대시보드 </div>
</button> <div class="col-md-3">
</li> <label>종료일</label>
<li class="nav-item" role="presentation"> <input type="date" name="end_date" value="{{ end_date }}" class="form-control">
<button class="nav-link" id="upload-tab" data-bs-toggle="tab" data-bs-target="#upload-panel" type="button" role="tab"> </div>
<i class="bi bi-cloud-upload"></i> 파일 업로드 <div class="col-md-2">
</button> <label>대분류</label>
</li> <select name="ca01" class="form-select">
<li class="nav-item" role="presentation"> <option value="전체">전체</option>
<button class="nav-link" id="backup-tab" data-bs-toggle="tab" data-bs-target="#backup-panel" type="button" role="tab"> </select>
<i class="bi bi-cloud-check"></i> 백업 관리 </div>
</button> <div class="col-md-2">
</li> <label>소분류</label>
</ul> <select name="ca03" class="form-select">
<option value="전체">전체</option>
</select>
</div>
<div class="col-md-2 align-self-end">
<button type="submit" class="btn btn-primary w-100">조회</button>
</div>
</form>
<!-- 탭 콘텐츠 --> <hr>
<div class="tab-content">
<!-- ===== 대시보드 탭 ===== -->
<div class="tab-pane fade show active" id="dashboard-panel" role="tabpanel">
<!-- 통계 카드 -->
<div class="row">
<div class="col-md-6 col-lg-3">
<div class="stat-card okpos-product">
<h3>OKPOS 상품별</h3>
<div class="stat-value" id="okpos-product-count">-</div>
<div class="stat-label">총 데이터</div>
<div class="stat-label" id="okpos-product-days">-</div>
<div class="stat-date" id="okpos-product-date">최종: -</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="stat-card okpos-receipt">
<h3>OKPOS 영수증</h3>
<div class="stat-value" id="okpos-receipt-count">-</div>
<div class="stat-label">총 데이터</div>
<div class="stat-label" id="okpos-receipt-days">-</div>
<div class="stat-date" id="okpos-receipt-date">최종: -</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="stat-card upsolution">
<h3>UPSolution</h3>
<div class="stat-value" id="upsolution-count">-</div>
<div class="stat-label">총 데이터</div>
<div class="stat-label" id="upsolution-days">-</div>
<div class="stat-date" id="upsolution-date">최종: -</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="stat-card weather">
<h3>날씨 데이터</h3>
<div class="stat-value" id="weather-count">-</div>
<div class="stat-label">총 데이터</div>
<div class="stat-label" id="weather-days">-</div>
<div class="stat-date" id="weather-date">최종: -</div>
</div>
</div>
</div>
<!-- 주간 예보 테이블 --> <table class="table table-bordered mt-3" id="resultTable">
<div style="margin-top: 30px;">
<h5 style="margin-bottom: 20px; font-weight: 600; color: #333;">
<i class="bi bi-calendar-event"></i> 이번주 예상 날씨 & 방문객
</h5>
<div class="table-responsive">
<table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th>날짜</th> <th>대분류</th><th>중분류</th><th>소분류</th><th>상품명</th>
<th>최저기온</th> <th>수량</th><th>총매출액</th><th>총할인액</th><th>실매출액</th>
<th>최고기온</th>
<th>강수량</th>
<th>습도</th>
<th>예상 방문객</th>
</tr> </tr>
</thead> </thead>
<tbody id="weekly-forecast-table"> <tbody></tbody>
<tfoot>
<tr> <tr>
<td colspan="6" class="text-center text-muted">데이터 로딩 중...</td> <th colspan="4" class="text-end">합계</th>
<th id="sum_qty">0</th>
<th id="sum_tot_amount">0</th>
<th id="sum_tot_discount">0</th>
<th id="sum_actual_amount">0</th>
</tr> </tr>
</tbody> </tfoot>
</table> </table>
</div>
</div>
<!-- 방문객 추이 그래프 --> <script>
<div style="margin-top: 30px;"> async function fetchAndFillSelect(url, selectElem) {
<h5 style="margin-bottom: 20px; font-weight: 600; color: #333;"> const res = await fetch(url);
<i class="bi bi-graph-up"></i> 방문객 추이 const list = await res.json();
</h5> selectElem.innerHTML = '';
list.forEach(val => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
selectElem.appendChild(opt);
});
}
<!-- 날짜 범위 선택 --> document.addEventListener('DOMContentLoaded', async () => {
<div class="date-range-picker"> const ca01Select = document.querySelector('select[name="ca01"]');
<input type="date" id="trend-start-date" class="form-control" style="max-width: 150px;"> const ca03Select = document.querySelector('select[name="ca03"]');
<span style="display: flex; align-items: center;">~</span>
<input type="date" id="trend-end-date" class="form-control" style="max-width: 150px;">
<button class="btn btn-primary btn-sm" onclick="loadVisitorTrend()">조회</button>
<button class="btn btn-outline-primary btn-sm" onclick="resetTrendDate()">최근 1개월</button>
</div>
<!-- 그래프 --> // 대분류 목록 불러오기
<div class="chart-container"> await fetchAndFillSelect('/api/ca01_list', ca01Select);
<canvas id="visitor-trend-chart"></canvas>
</div>
</div>
</div>
<!-- ===== 파일 업로드 탭 ===== --> // 대분류 변경 시 소분류 목록 갱신
<div class="tab-pane fade" id="upload-panel" role="tabpanel"> ca01Select.addEventListener('change', async () => {
<!-- 시스템 상태 --> const selectedCa01 = ca01Select.value;
<div class="alert alert-info" role="alert"> await fetchAndFillSelect(`/api/ca03_list?ca01=${encodeURIComponent(selectedCa01)}`, ca03Select);
<i class="bi bi-info-circle"></i>
<strong>시스템 상태:</strong>
데이터베이스: <span id="db-status" class="badge bg-danger">연결 중...</span>
업로드 폴더: <span id="upload-folder-status" class="badge bg-danger">확인 중...</span>
</div>
<!-- 드래그 앤 드롭 영역 -->
<div class="drop-zone" id="drop-zone">
<i class="bi bi-cloud-upload" style="font-size: 48px; color: var(--primary-color); margin-bottom: 15px;"></i>
<h5 style="color: #333; margin: 10px 0;">파일을 여기에 드래그하세요</h5>
<p style="color: #666; margin: 0;">또는</p>
<button class="btn btn-primary btn-custom" style="margin-top: 10px;">
파일 선택
</button>
<p style="color: #999; font-size: 12px; margin-top: 15px;">
지원 형식: OKPOS (일자별 상품별, 영수증별매출상세현황), UPSOLUTION<br>
최대 파일 크기: 100MB
</p>
</div>
<!-- 선택된 파일 목록 -->
<div class="file-list" id="file-list"></div>
<!-- 액션 버튼 -->
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-success btn-custom" id="upload-btn" onclick="uploadFiles()">
<i class="bi bi-check-circle"></i> 업로드
</button>
<button class="btn btn-secondary btn-custom" id="clear-btn" onclick="clearFileList()">
<i class="bi bi-x-circle"></i> 초기화
</button>
</div>
<!-- 업로드 진행 표시 -->
<div id="upload-progress" style="margin-top: 20px; display: none;">
<div class="progress" style="height: 25px;">
<div class="progress-bar bg-success" id="progress-bar" style="width: 0%;">
<span id="progress-text">0%</span>
</div>
</div>
<p id="progress-message" style="margin-top: 10px; color: #666;"></p>
</div>
<!-- 업로드 결과 -->
<div id="upload-result" style="margin-top: 20px;"></div>
</div>
<!-- ===== 백업 관리 탭 ===== -->
<div class="tab-pane fade" id="backup-panel" role="tabpanel">
<div style="display: flex; gap: 10px; margin-bottom: 20px;">
<button class="btn btn-success btn-custom" onclick="createBackup()">
<i class="bi bi-plus-circle"></i> 새 백업 생성
</button>
<button class="btn btn-info btn-custom" onclick="loadBackupList()">
<i class="bi bi-arrow-clockwise"></i> 새로고침
</button>
</div>
<!-- 백업 목록 -->
<div id="backup-list"></div>
</div>
</div>
</div>
<!-- 알림 영역 -->
<div id="alert-container" style="position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 400px;"></div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// 글로벌 변수
const FILE_LIST = [];
let visitorTrendChart = null;
// ===== 초기화 =====
document.addEventListener('DOMContentLoaded', function() {
initializeDatePickers();
loadDashboard();
loadFileUploadUI();
setInterval(loadDashboard, 30000); // 30초마다 대시보드 새로고침
}); });
// 날짜 피커 초기화 // 초기 소분류 목록 불러오기
function initializeDatePickers() { await fetchAndFillSelect('/api/ca03_list', ca03Select);
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
document.getElementById('trend-start-date').valueAsDate = thirtyDaysAgo; // 폼 제출 이벤트 처리
document.getElementById('trend-end-date').valueAsDate = today; document.getElementById('filterForm').addEventListener('submit', async function(e) {
}
// ===== 대시보드 로드 =====
async function loadDashboard() {
await Promise.all([
loadOKPOSProductStats(),
loadOKPOSReceiptStats(),
loadUPSolutionStats(),
loadWeatherStats(),
loadWeeklyForecast(),
loadVisitorTrend()
]);
}
async function loadOKPOSProductStats() {
try {
const response = await fetch('/api/dashboard/okpos-product');
const data = await response.json();
document.getElementById('okpos-product-count').textContent = data.total_records.toLocaleString();
document.getElementById('okpos-product-days').textContent = `${data.total_days}`;
document.getElementById('okpos-product-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (e) {
console.error('OKPOS 상품별 통계 로드 실패:', e);
}
}
async function loadOKPOSReceiptStats() {
try {
const response = await fetch('/api/dashboard/okpos-receipt');
const data = await response.json();
document.getElementById('okpos-receipt-count').textContent = data.total_records.toLocaleString();
document.getElementById('okpos-receipt-days').textContent = `${data.total_days}`;
document.getElementById('okpos-receipt-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (e) {
console.error('OKPOS 영수증 통계 로드 실패:', e);
}
}
async function loadUPSolutionStats() {
try {
const response = await fetch('/api/dashboard/upsolution');
const data = await response.json();
document.getElementById('upsolution-count').textContent = data.total_records.toLocaleString();
document.getElementById('upsolution-days').textContent = `${data.total_days}`;
document.getElementById('upsolution-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (e) {
console.error('UPSOLUTION 통계 로드 실패:', e);
}
}
async function loadWeatherStats() {
try {
const response = await fetch('/api/dashboard/weather');
const data = await response.json();
document.getElementById('weather-count').textContent = data.total_records.toLocaleString();
document.getElementById('weather-days').textContent = `${data.total_days}`;
document.getElementById('weather-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (e) {
console.error('날씨 통계 로드 실패:', e);
}
}
async function loadWeeklyForecast() {
try {
const response = await fetch('/api/dashboard/weekly-forecast');
const data = await response.json();
let html = '';
data.forecast_data.forEach(day => {
html += `
<tr>
<td><strong>${day.date} (${day.day})</strong></td>
<td>${day.min_temp !== null ? day.min_temp + '°C' : '-'}</td>
<td>${day.max_temp !== null ? day.max_temp + '°C' : '-'}</td>
<td>${day.precipitation.toFixed(1)}mm</td>
<td>${day.humidity !== null ? day.humidity + '%' : '-'}</td>
<td><strong>${day.expected_visitors.toLocaleString()}명</strong></td>
</tr>
`;
});
document.getElementById('weekly-forecast-table').innerHTML = html;
} catch (e) {
console.error('주간 예보 로드 실패:', e);
}
}
async function loadVisitorTrend() {
try {
const startDate = document.getElementById('trend-start-date').value;
const endDate = document.getElementById('trend-end-date').value;
const response = await fetch(`/api/dashboard/visitor-trend?start_date=${startDate}&end_date=${endDate}`);
const data = await response.json();
const ctx = document.getElementById('visitor-trend-chart');
if (visitorTrendChart) {
visitorTrendChart.destroy();
}
visitorTrendChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.dates,
datasets: [{
label: '방문객',
data: data.visitors,
borderColor: 'var(--primary-color)',
backgroundColor: 'rgba(13, 110, 253, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: 'var(--primary-color)',
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top'
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
} catch (e) {
console.error('방문객 추이 로드 실패:', e);
}
}
function resetTrendDate() {
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
document.getElementById('trend-start-date').valueAsDate = thirtyDaysAgo;
document.getElementById('trend-end-date').valueAsDate = today;
loadVisitorTrend();
}
// ===== 파일 업로드 =====
function loadFileUploadUI() {
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
dropZone.querySelector('button').addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '.xlsx,.xls,.csv';
input.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
input.click();
});
checkSystemStatus();
loadBackupList();
}
function handleFiles(files) { const formData = new FormData(this);
for (let file of files) { const params = new URLSearchParams(formData);
FILE_LIST.push(file);
}
updateFileList();
}
function updateFileList() { const res = await fetch('/search?' + params.toString());
const fileListDiv = document.getElementById('file-list'); if (!res.ok) {
let html = ''; alert('조회 중 오류가 발생했습니다.');
FILE_LIST.forEach((file, index) => {
html += `
<div class="file-item">
<div>
<div class="file-name">
<i class="bi bi-file-earmark"></i> ${file.name}
</div>
<small style="color: #999;">${(file.size / 1024 / 1024).toFixed(2)} MB</small>
</div>
<i class="bi bi-x-circle file-remove" style="cursor: pointer; color: var(--danger-color);" onclick="removeFile(${index})"></i>
</div>
`;
});
fileListDiv.innerHTML = html;
}
function removeFile(index) {
FILE_LIST.splice(index, 1);
updateFileList();
}
function clearFileList() {
FILE_LIST.length = 0;
updateFileList();
document.getElementById('upload-result').innerHTML = '';
}
async function uploadFiles() {
if (FILE_LIST.length === 0) {
showAlert('업로드할 파일을 선택하세요.', 'warning');
return; return;
} }
const data = await res.json();
const formData = new FormData(); const tbody = document.querySelector('#resultTable tbody');
FILE_LIST.forEach(file => { tbody.innerHTML = '';
formData.append('files', file);
});
document.getElementById('upload-progress').style.display = 'block'; // 합계 초기화
document.getElementById('upload-btn').disabled = true; let sum_qty = 0;
let sum_tot_amount = 0;
let sum_tot_discount = 0;
let sum_actual_amount = 0;
try { if (data.length === 0) {
const response = await fetch('/api/upload', { tbody.innerHTML = `<tr><td colspan="8" class="text-center">조회 결과가 없습니다.</td></tr>`;
method: 'POST',
body: formData
});
const data = await response.json();
let resultHtml = data.success ? '<div class="alert alert-success">' : '<div class="alert alert-warning">';
resultHtml += '<strong>업로드 완료!</strong><br>';
data.files.forEach(file => {
const icon = file.status === 'success' ? '✓' : '✗';
resultHtml += `${icon} ${file.filename}: ${file.message}<br>`;
});
resultHtml += '</div>';
document.getElementById('upload-result').innerHTML = resultHtml;
setTimeout(() => {
clearFileList();
loadDashboard();
}, 2000);
} catch (e) {
showAlert('업로드 실패: ' + e.message, 'danger');
} finally {
document.getElementById('upload-progress').style.display = 'none';
document.getElementById('upload-btn').disabled = false;
}
}
// ===== 백업 관리 =====
async function createBackup() {
try {
const response = await fetch('/api/backup', { method: 'POST' });
const data = await response.json();
if (data.success) {
showAlert('백업이 생성되었습니다.', 'success');
loadBackupList();
} else { } else {
showAlert('백업 생성 실패: ' + data.message, 'danger'); data.forEach(row => {
} const tr = document.createElement('tr');
} catch (e) { tr.innerHTML = `
showAlert('백업 생성 오류: ' + e.message, 'danger'); <td>${row.ca01}</td>
} <td>${row.ca02}</td>
} <td>${row.ca03}</td>
<td>${row.name}</td>
async function loadBackupList() { <td>${row.qty}</td>
try { <td>${row.tot_amount}</td>
const response = await fetch('/api/backups'); <td>${row.tot_discount}</td>
const data = await response.json(); <td>${row.actual_amount}</td>
let html = '';
if (data.backups.length === 0) {
html = '<div class="alert alert-info">생성된 백업이 없습니다.</div>';
} else {
data.backups.forEach(backup => {
const sizeInMB = (backup.size / 1024 / 1024).toFixed(2);
html += `
<div class="backup-item">
<div class="backup-info">
<div class="backup-filename">
<i class="bi bi-file-earmark-zip"></i> ${backup.filename}
</div>
<div class="backup-size">크기: ${sizeInMB}MB | 생성: ${backup.created}</div>
</div>
<div class="backup-actions">
<button class="btn btn-sm btn-primary" onclick="restoreBackup('${backup.filename}')">
<i class="bi bi-arrow-counterclockwise"></i> 복구
</button>
</div>
</div>
`; `;
tbody.appendChild(tr);
// 합계 계산
sum_qty += Number(row.qty) || 0;
sum_tot_amount += Number(row.tot_amount) || 0;
sum_tot_discount += Number(row.tot_discount) || 0;
sum_actual_amount += Number(row.actual_amount) || 0;
}); });
} }
document.getElementById('backup-list').innerHTML = html;
} catch (e) {
console.error('백업 목록 로드 실패:', e);
}
}
function restoreBackup(filename) { // 합계 출력
if (confirm(`${filename}에서 데이터베이스를 복구하시겠습니까?`)) { document.getElementById('sum_qty').textContent = sum_qty.toLocaleString();
fetch('/api/restore', { document.getElementById('sum_tot_amount').textContent = sum_tot_amount.toLocaleString();
method: 'POST', document.getElementById('sum_tot_discount').textContent = sum_tot_discount.toLocaleString();
headers: { 'Content-Type': 'application/json' }, document.getElementById('sum_actual_amount').textContent = sum_actual_amount.toLocaleString();
body: JSON.stringify({ filename }) });
}).then(r => r.json()).then(result => { });
if (result.success) { </script>
showAlert('데이터베이스가 복구되었습니다.', 'success');
loadDashboard();
} else {
showAlert('복구 실패: ' + result.message, 'danger');
}
}).catch(e => showAlert('복구 오류: ' + e.message, 'danger'));
}
}
// ===== 시스템 상태 =====
async function checkSystemStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
document.getElementById('db-status').textContent = data.database ? '연결됨' : '연결 안됨';
document.getElementById('db-status').className = `badge ${data.database ? 'bg-success' : 'bg-danger'}`;
document.getElementById('upload-folder-status').textContent = data.upload_folder ? '정상' : '오류';
document.getElementById('upload-folder-status').className = `badge ${data.upload_folder ? 'bg-success' : 'bg-danger'}`;
} catch (e) {
console.error('시스템 상태 로드 실패:', e);
}
}
// ===== 알림 =====
function showAlert(message, type = 'info') {
const alertContainer = document.getElementById('alert-container');
const alertId = 'alert-' + Date.now();
const alertHtml = `
<div id="${alertId}" class="alert alert-${type} alert-dismissible fade show" role="alert" style="animation: slideIn 0.3s ease;">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
alertContainer.insertAdjacentHTML('beforeend', alertHtml);
setTimeout(() => {
const alertElement = document.getElementById(alertId);
if (alertElement) alertElement.remove();
}, 5000);
}
</script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>월별 입장객 현황</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
.diff-positive { color: blue; }
.diff-negative { color: red; }
.diff-zero { color: gray; }
table th, table td { vertical-align: middle; }
table th { text-align: center; }
table td { text-align: right; } /* 숫자 우측 정렬 */
#visitorChart {
width: 90% ;
max-width: 1200px;
min-height: 600px;
margin: 30px auto;
}
</style>
</head>
<body class="p-4">
<h2>월별 입장객 현황 (2017년 ~ 현재)</h2>
<table class="table table-bordered table-sm">
<thead class="table-secondary">
<tr>
<th>연도</th>
<th>1월</th><th>2월</th><th>3월</th><th>4월</th><th>5월</th><th>6월</th>
<th>7월</th><th>8월</th><th>9월</th><th>10월</th><th>11월</th><th>12월</th>
<th>합계</th>
</tr>
</thead>
<tbody id="dataBody">
<!-- 데이터가 여기에 삽입됩니다 -->
</tbody>
</table>
<canvas id="visitorChart"></canvas>
<script>
const visitorData = JSON.parse('{{ visitor_data | safe }}');
function formatNumber(num) {
return num.toLocaleString('ko-KR');
}
function formatDiff(diff) {
if (diff > 0) return `<span class="diff-positive">(+${formatNumber(diff)})</span>`;
else if (diff < 0) return `<span class="diff-negative">(${formatNumber(diff)})</span>`;
else return `<span class="diff-zero">(0)</span>`;
}
function renderTable(data) {
const years = Object.keys(data).sort();
const tbody = document.getElementById('dataBody');
tbody.innerHTML = '';
years.forEach((year, idx) => {
const currYearData = data[year];
const prevYearData = idx > 0 ? data[years[idx-1]] : null;
let row = `<tr><th style="text-align:center">${year}</th>`;
let annualSum = 0;
for(let m=0; m<12; m++) {
const curr = currYearData[m] || 0;
annualSum += curr;
let diff = prevYearData ? (curr - (prevYearData[m] || 0)) : 0;
row += `<td>${formatNumber(curr)}<br>${formatDiff(diff)}</td>`;
}
row += `<th style="text-align:right">${formatNumber(annualSum)}</th></tr>`;
tbody.insertAdjacentHTML('beforeend', row);
});
}
function renderChart(data) {
const ctx = document.getElementById('visitorChart').getContext('2d');
const labels = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
// 연도를 숫자형으로 정렬
const years = Object.keys(data).map(Number).sort((a, b) => a - b);
// 각 연도별 데이터셋 생성
const datasets = years.map((year, i) => ({
label: year.toString(),
data: data[year],
backgroundColor: `hsla(${(i * 40) % 360}, 70%, 50%, 0.7)`,
borderColor: `hsla(${(i * 40) % 360}, 70%, 50%, 1)`,
borderWidth: 1
}));
const chartData = {
labels: labels,
datasets: datasets
};
new Chart(ctx, {
type: 'bar',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: true,
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '입장객 수'
},
ticks: {
callback: value => value.toLocaleString('ko-KR')
}
}
},
plugins: {
legend: {
position: 'top'
},
title: {
display: true,
text: '월별 입장객 수 (연도별)'
},
tooltip: {
callbacks: {
label: context => `입장객 수: ${context.parsed.y.toLocaleString('ko-KR')}`
}
}
}
}
});
}
document.addEventListener('DOMContentLoaded', () => {
renderTable(visitorData);
renderChart(visitorData);
});
</script>
</body>
</html>

View File

@ -1,69 +0,0 @@
{% extends "base.html" %}
{% block title %}파일 업로드 - First Garden POS{% endblock %}
{% block extra_css %}
<link href="{{ url_for('static', filename='css/upload.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="tab-pane fade show active" id="upload-panel" role="tabpanel">
<!-- 시스템 상태 -->
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i>
<strong>시스템 상태:</strong>
데이터베이스: <span id="db-status" class="badge bg-danger">연결 중...</span>
업로드 폴더: <span id="upload-folder-status" class="badge bg-danger">확인 중...</span>
</div>
<!-- 드래그 앤 드롭 영역 -->
<div class="drop-zone" id="drop-zone">
<i class="bi bi-cloud-upload" style="font-size: 48px; color: var(--primary-color); margin-bottom: 15px;"></i>
<h5 style="color: #333; margin: 10px 0;">파일을 여기에 드래그하세요</h5>
<p style="color: #666; margin: 0;">또는</p>
<button class="btn btn-primary btn-custom" id="file-select-btn" style="margin-top: 10px;">
파일 선택
</button>
<p style="color: #999; font-size: 12px; margin-top: 15px;">
지원 형식: OKPOS (일자별 상품별, 영수증별매출상세현황), UPSOLUTION<br>
최대 파일 크기: 100MB
</p>
</div>
<!-- 선택된 파일 목록 -->
<div class="file-list" id="file-list"></div>
<!-- 액션 버튼 -->
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-success btn-custom" id="upload-btn" onclick="uploadFiles()">
<i class="bi bi-check-circle"></i> 업로드
</button>
<button class="btn btn-secondary btn-custom" id="clear-btn" onclick="clearFileList()">
<i class="bi bi-x-circle"></i> 초기화
</button>
</div>
<!-- 업로드 진행 표시 -->
<div id="upload-progress" style="margin-top: 20px; display: none;">
<div class="progress" style="height: 25px;">
<div class="progress-bar bg-success" id="progress-bar" style="width: 0%;">
<span id="progress-text">0%</span>
</div>
</div>
<p id="progress-message" style="margin-top: 10px; color: #666;"></p>
</div>
<!-- 업로드 결과 -->
<div id="upload-result" style="margin-top: 20px;"></div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
<script>
// 파일 업로드 UI 초기화
document.addEventListener('DOMContentLoaded', function() {
initializeUploadUI();
});
</script>
{% endblock %}

View File

@ -1,130 +0,0 @@
# Dockerfile - First Garden Static Analysis Service
# Python 3.11 기반의 가벼운 이미지 사용
FROM python:3.11-slim-bullseye
# 메타데이터 설정
LABEL maintainer="First Garden Team"
LABEL description="First Garden Static Analysis - Data Collection & Visitor Forecasting Service"
# 작업 디렉토리 설정
WORKDIR /app
# 타임존 설정
ENV TZ=Asia/Seoul
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# 시스템 패키지 업데이트 및 필수 도구 설치
# mysqldump 및 mysql 클라이언트 추가 (DB 백업/복구 용)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
build-essential \
libmysqlclient-dev \
libssl-dev \
libffi-dev \
curl \
wget \
git \
cron \
mariadb-client \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Python 의존성 설치
COPY requirements.txt ./
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
pip install --no-cache-dir -r requirements.txt
# 앱 코드 복사
COPY . .
# 로그 디렉토리 생성
RUN mkdir -p /app/logs /app/data /app/output /app/uploads /app/dbbackup && \
chmod 755 /app/logs /app/data /app/output /app/uploads /app/dbbackup
# 크론 작업 설정: 매일 11시 UTC (서울 시간 20시)에 daily_run.py 실행
RUN echo "0 11 * * * cd /app && python daily_run.py >> /app/logs/daily_run.log 2>&1" > /etc/cron.d/daily-forecast && \
chmod 0644 /etc/cron.d/daily-forecast && \
crontab /etc/cron.d/daily-forecast
# 헬스체크 스크립트 생성
RUN cat > /app/healthcheck.sh << 'EOF'
#!/bin/bash
set -e
# DB 연결 확인
python -c "
import sys
sys.path.insert(0, '/app')
from conf import db
try:
session = db.get_session()
session.execute('SELECT 1')
session.close()
print('DB Connection OK')
except Exception as e:
print(f'DB Connection Failed: {e}')
sys.exit(1)
" && exit 0 || exit 1
EOF
RUN chmod +x /app/healthcheck.sh
# 컨테이너 시작 스크립트 생성
# Flask 웹 서버(포트 8889) + 크론 + 파일 감시 서비스 병렬 실행
RUN cat > /app/docker-entrypoint.sh << 'EOF'
#!/bin/bash
set -e
echo "[$(date +'%Y-%m-%d %H:%M:%S')] ========================================="
echo "[$(date +'%Y-%m-%d %H:%M:%S')] Starting First Garden Static Service"
echo "[$(date +'%Y-%m-%d %H:%M:%S')] ========================================="
# 크론 데몬 시작 (백그라운드)
# 일정 시간에 daily_run.py 자동 실행
echo "[$(date +'%Y-%m-%d %H:%M:%S')] Starting cron daemon..."
cron -f > /app/logs/cron.log 2>&1 &
CRON_PID=$!
echo "[$(date +'%Y-%m-%d %H:%M:%S')] Cron daemon started (PID: $CRON_PID)"
# file_watch.py 실행 (백그라운드)
# 로컬 파일 시스템 감시 및 자동 처리
echo "[$(date +'%Y-%m-%d %H:%M:%S')] Starting file watch service..."
cd /app
python lib/file_watch.py > /app/logs/file_watch.log 2>&1 &
WATCH_PID=$!
echo "[$(date +'%Y-%m-%d %H:%M:%S')] File watch service started (PID: $WATCH_PID)"
# Flask 웹 서버 시작 (포트 8889)
# 파일 업로드, DB 백업/복구 API 제공
echo "[$(date +'%Y-%m-%d %H:%M:%S')] Starting Flask file upload server (port 8889)..."
cd /app
python -c "from app.app import run_app; run_app()" > /app/logs/flask_app.log 2>&1 &
FLASK_PID=$!
echo "[$(date +'%Y-%m-%d %H:%M:%S')] Flask server started (PID: $FLASK_PID)"
# 신호 처리: 컨테이너 종료 시 모든 하위 프로세스 정리
trap "
echo '[$(date +\"%Y-%m-%d %H:%M:%S\")] Shutting down services...'
kill $CRON_PID $WATCH_PID $FLASK_PID 2>/dev/null || true
exit 0
" SIGTERM SIGINT
# 프로세스 모니터링
# 하위 프로세스 상태 확인
echo "[$(date +'%Y-%m-%d %H:%M:%S')] Service monitoring started"
while true; do
wait
done
EOF
chmod +x /app/docker-entrypoint.sh
# 포트 노출 선언
# 8889: Flask 파일 업로드 서버
# 5000: 기타 서비스 포트
EXPOSE 8889 5000
# 엔트리포인트 설정
ENTRYPOINT ["/app/docker-entrypoint.sh"]

View File

@ -1,90 +1,42 @@
# =================================================================== # 데이터베이스 접속 정보
# First Garden 정적 데이터 관리 시스템 설정 파일 (샘플)
# ===================================================================
# 이 파일을 config.yaml로 복사한 후 실제 값으로 수정하세요.
# 민감한 정보(비밀번호, API 키)는 .env 파일 사용을 권장합니다.
# ===================================================================
# ===== 데이터베이스 접속 정보 =====
# MariaDB/MySQL 데이터베이스 연결 설정
# 환경변수로 덮어쓰기 가능: DB_HOST, DB_USER, DB_PASSWORD, DB_NAME
database: database:
host: localhost # DB 호스트명 (Docker: mariadb, 로컬: localhost) host: # DB 호스트명 (docker-compose에서 사용하는 서비스명 mariadb)
user: your_db_user # DB 사용자명 user: # DB 사용자명
password: your_db_password # DB 비밀번호 password: # DB 비밀번호
name: your_db_name # 사용할 데이터베이스 이름 name: # 사용할 데이터베이스 이름
# ===== 테이블 설정 ===== # table 이름 정의
# 모든 테이블명 앞에 붙는 접두 table_prefix: DB 접두
table_prefix: fg_manager_static_
# 사용되는 테이블 목록 (참고용)
tables: tables:
air: 대기정보 테이블 # 미세먼지 등 대기질 데이터 air: 대기정보 테이블
weather: 종관기상관측 테이블 # 기온, 강수량 등 날씨 데이터 weather: 종관기상관측 테이블
ga4: GA4 테이블 # Google Analytics 방문자 데이터 ga4: GA4 테이블
pos: POS 데이터 테이블 # 매출 및 상품 데이터 pos: POS 데이터 테이블
pos_deactivate: 비활성 데이터 목록 # 입장 처리에서 제외할 데이터 pos_deactivate: 입장처리에서 반영하지 않을 데이터를 관리할 목록 테이블
holiday: 휴일 정보 테이블 # 공휴일 및 휴무일 정보 holiday: holiday
# ===== 공공데이터포털 API 설정 ===== # 대기환경 API 설정
# Data.go.kr 에서 발급받은 API 키
# 환경변수: DATA_API_SERVICE_KEY
DATA_API: DATA_API:
serviceKey: "YOUR_API_KEY_HERE" # 공공데이터포털 API 인증키 serviceKey: "API_KEY"
startDt: "20170101" # 데이터 수집 시작 날짜 (YYYYMMDD) startDt: "20170101"
endDt: "20250701" # 데이터 수집 종료 날짜 (YYYYMMDD) endDt: "20250701"
# 대기질 측정소 설정
air: air:
station_name: # 측정소명 리스트 station_name:
- "운정" # 예: 운정, 일산, 고양 등 - "운정"
# 날씨 관측소 설정
weather: weather:
stnIds: # 기상청 관측소 ID stnIds:
- 99 # 예: 99 (파주), 108 (서울) 등 - 99
# ===== Google Analytics 4 설정 ===== # GA4 설정
# GA4 API를 통한 방문자 데이터 수집
# 환경변수: GA4_API_TOKEN, GA4_PROPERTY_ID
ga4: ga4:
token: YOUR_GA4_TOKEN # GA4 API 토큰 token: TOKEN
property_id: 12345678 # GA4 속성 ID (숫자) property_id: PROPERTY_ID
service_account_file: "./conf/service-account-credentials.json" # 서비스 계정 JSON service_account_file: "./service-account-credentials.json"
startDt: "20230101" # 데이터 수집 시작 날짜 startDt: "20230101"
endDt: "20250701" # 데이터 수집 종료 날짜 endDt: "20250701"
max_rows_per_request: 10000 # API 요청당 최대 행 수 max_rows_per_request: 10000
# ===== POS 시스템 설정 ===== max_workers: 4 # 병렬 처리할 worker 수
POS: debug: true # 디버그 모드 여부 (true/false)
# 방문객으로 분류할 매출 카테고리 force_update: false # 중복된 날짜의 데이터를 덮어씌우려면 true, 아니면 false
# 환경변수: VISITOR_CATEGORIES (쉼표 구분)
VISITOR_CA:
- 입장료 # 일반 입장료
- 티켓 # 각종 티켓
- 기업제휴 # 기업 제휴 티켓
# ===== 방문객 예측 모델 가중치 =====
# 날씨 요소가 방문객 수에 미치는 영향도
FORECAST_WEIGHT:
visitor_forecast_multiplier: 0.5 # 최종 예측값 조정 (0.0 ~ 1.0)
minTa: 1.0 # 최저기온 영향도
maxTa: 1.0 # 최고기온 영향도
sumRn: 10.0 # 강수량 영향도 (높을수록 큰 영향)
avgRhm: 1.0 # 습도 영향도
pm25: 1.0 # 미세먼지 영향도
is_holiday: 20 # 휴일 가중치 (휴일 시 방문객 증가)
# ===== 시스템 설정 =====
max_workers: 4 # 병렬 처리 워커 수 (CPU 코어 수 권장)
debug: false # 디버그 모드 (개발: true, 운영: false)
force_update: false # 기존 데이터 덮어쓰기 여부
# ===== UPSolution POS 연동 =====
# UPSolution API 접속 정보
# 환경변수: UPSOLUTION_ID, UPSOLUTION_CODE, UPSOLUTION_PW
upsolution:
id: "your_upsolution_id" # UPSolution 계정 ID
code: "your_store_code" # 점포 코드
pw: "your_password" # 계정 비밀번호

View File

@ -1,105 +1,25 @@
# db.py # db.py
import os import os
import logging from sqlalchemy import create_engine
from sqlalchemy import create_engine, event, exc, pool from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import sessionmaker, scoped_session import yaml
logger = logging.getLogger(__name__) # db.py 파일 위치 기준 상위 디렉토리 (프로젝트 루트)
# 프로젝트 루트 경로 설정
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
CONFIG_PATH = os.path.join(BASE_DIR, 'conf', 'config.yaml')
def get_db_config(): def load_config(path=CONFIG_PATH):
"""환경변수에서 데이터베이스 설정 로드""" with open(path, 'r', encoding='utf-8') as f:
return { return yaml.safe_load(f)
'host': os.getenv('DB_HOST', 'localhost'),
'user': os.getenv('DB_USER', 'firstgarden'),
'password': os.getenv('DB_PASSWORD', 'Fg9576861!'),
'name': os.getenv('DB_NAME', 'firstgarden')
}
db_cfg = get_db_config() config = load_config()
db_cfg = config['database']
# DB URL 구성 db_url = f"mysql+pymysql://{db_cfg['user']}:{db_cfg['password']}@{db_cfg['host']}/{db_cfg['name']}?charset=utf8mb4"
db_url = (
f"mysql+pymysql://{db_cfg.get('user')}:"
f"{db_cfg.get('password')}@{db_cfg.get('host')}/"
f"{db_cfg.get('name')}?charset=utf8mb4"
)
# MySQL 엔진 생성 (재연결 및 연결 풀 설정) # MySQL 연결이 끊겼을 때 자동 재시도 옵션 포함
engine = create_engine( engine = create_engine(db_url, pool_pre_ping=True)
db_url,
poolclass=pool.QueuePool,
pool_pre_ping=True, # 연결 전 핸들 확인
pool_recycle=3600, # 3600초(1시간)마다 재연결
pool_size=10, # 연결 풀 크기
max_overflow=20, # 추가 오버플로우 연결 수
echo=False, # SQL 출력 (디버그용)
connect_args={
'connect_timeout': 10,
'charset': 'utf8mb4'
}
)
# 연결 에러 발생 시 자동 재연결
@event.listens_for(pool.Pool, "connect")
def receive_connect(dbapi_conn, connection_record):
"""DB 연결 성공 로그"""
logger.debug("DB 연결 성공")
@event.listens_for(pool.Pool, "checkout")
def receive_checkout(dbapi_conn, connection_record, connection_proxy):
"""연결 풀에서 체크아웃할 때"""
pass
@event.listens_for(pool.Pool, "checkin")
def receive_checkin(dbapi_conn, connection_record):
"""연결 풀로 반환할 때"""
pass
# 세션 팩토리
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
SessionLocal = scoped_session(Session)
def get_engine():
"""엔진 반환"""
return engine
def get_session(): def get_session():
"""새로운 세션 반환""" return Session()
session = Session()
try:
# 연결 테스트
session.execute('SELECT 1')
except exc.DatabaseError as e:
logger.error(f"DB 연결 실패: {e}")
session.close()
raise
return session
def get_scoped_session():
"""스코프 세션 반환 (스레드 안전)"""
return SessionLocal
def close_session():
"""세션 종료"""
SessionLocal.remove()
class DBSession:
"""컨텍스트 매니저를 사용한 세션 관리"""
def __init__(self):
self.session = None
def __enter__(self):
self.session = get_session()
return self.session
def __exit__(self, exc_type, exc_val, exc_tb):
if self.session:
if exc_type:
self.session.rollback()
logger.error(f"트랜잭션 롤백: {exc_type.__name__}: {exc_val}")
else:
self.session.commit()
self.session.close()

View File

@ -1,7 +1,7 @@
# db_schema.py # db_schema.py
import os import os
import yaml import yaml
from sqlalchemy import Table, Column, Date, Integer, String, Float, Text, MetaData, UniqueConstraint, DateTime, Time, PrimaryKeyConstraint, Index from sqlalchemy import Table, Column, Date, Integer, String, Float, Text, MetaData, UniqueConstraint, DateTime
from sqlalchemy.sql import func from sqlalchemy.sql import func
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
@ -164,15 +164,6 @@ ga4 = Table(
mysql_charset='utf8mb4' mysql_charset='utf8mb4'
) )
holiday = Table(
get_full_table_name('holiday'), metadata,
Column('date', String(8), primary_key=True, comment='날짜 (YYYYMMDD)'),
Column('name', String(50), nullable=False, comment='휴일명'),
Column('created_at', DateTime, server_default=func.now(), comment='등록일시'),
Column('updated_at', DateTime, server_default=func.now(), onupdate=func.now(), comment='수정일시'),
comment='한국천문연구원 특일정보'
)
pos = Table( pos = Table(
get_full_table_name('pos'), metadata, get_full_table_name('pos'), metadata,
Column('idx', Integer, primary_key=True, autoincrement=True), Column('idx', Integer, primary_key=True, autoincrement=True),
@ -189,71 +180,11 @@ pos = Table(
UniqueConstraint('date', 'ca01', 'ca02', 'ca03', 'name', 'barcode', name='uniq_pos_composite') UniqueConstraint('date', 'ca01', 'ca02', 'ca03', 'name', 'barcode', name='uniq_pos_composite')
) )
pos_billdata = Table( holiday = Table(
get_full_table_name('pos_billdata'), metadata, get_full_table_name('holiday'), metadata,
Column('sale_date', Date, nullable=False), Column('date', String(8), primary_key=True, comment='날짜 (YYYYMMDD)'),
Column('shop_cd', String(20), nullable=False), Column('name', String(50), nullable=False, comment='휴일명'),
Column('pos_no', Integer, nullable=False), Column('created_at', DateTime, server_default=func.now(), comment='등록일시'),
Column('bill_no', Integer, nullable=False), Column('updated_at', DateTime, server_default=func.now(), onupdate=func.now(), comment='수정일시'),
Column('product_cd', String(20), nullable=False), comment='한국천문연구원 특일정보'
Column('division', String(10)),
Column('table_no', String(20)),
Column('order_time', Time),
Column('pay_time', Time),
Column('barcode', String(20)),
Column('product_name', String(100)),
Column('qty', Integer),
Column('tot_sale_amt', Integer),
Column('erp_cd', String(50)),
Column('remark', Text),
Column('dc_amt', Integer),
Column('dc_type', String(50)),
Column('dcm_sale_amt', Integer),
Column('net_amt', Integer),
Column('vat_amt', Integer),
PrimaryKeyConstraint('sale_date', 'shop_cd', 'pos_no', 'bill_no', 'product_cd')
)
pos_ups_billdata = Table(
get_full_table_name('pos_ups_billdata'), metadata,
Column('sale_date', DateTime, nullable=False),
Column('shop_name', String(100), nullable=False),
Column('pos_no', String(20), nullable=False),
Column('bill_no', String(20), nullable=False),
Column('product_cd', String(20), nullable=False),
Column('ca01', String(50)),
Column('ca02', String(50)),
Column('ca03', String(50)),
Column('product_name', String(100)),
Column('barcode', String(20)),
Column('amt', Integer),
Column('qty', Integer),
Column('tot_sale_amt', Integer),
Column('dc_amt', Integer),
Column('dcm_sale_amt', Integer),
Column('net_amt', Integer),
Column('vat_amt', Integer),
Column('cash_receipt', Integer),
Column('card', Integer),
# PrimaryKeyConstraint 생략
mysql_engine='InnoDB',
mysql_charset='utf8mb4'
)
# 인덱스 추가
Index('idx_sale_shop_pos_product', pos_ups_billdata.c.sale_date, pos_ups_billdata.c.shop_name, pos_ups_billdata.c.pos_no, pos_ups_billdata.c.product_cd)
Index('idx_category', pos_ups_billdata.c.ca01, pos_ups_billdata.c.ca02, pos_ups_billdata.c.ca03)
Index('idx_product_barcode', pos_ups_billdata.c.product_name, pos_ups_billdata.c.barcode)
pos_shop_name = Table(
get_full_table_name('pos_shop_name'), metadata,
Column('shop_cd', String(20), primary_key=True, nullable=False),
Column('shop_name', String(100), nullable=False),
Column('used', Integer, nullable=False, default=1, comment='사용여부 (1=사용, 0=미사용)'),
Column('created_at', DateTime, server_default=func.current_timestamp(), comment='등록일시'),
Column('updated_at', DateTime, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), comment='수정일시'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
) )

View File

@ -1,25 +0,0 @@
CREATE TABLE `fg_manager_static_pos_ups_billdata` (
`sale_date` DATETIME NOT NULL,
`shop_name` VARCHAR(100) NOT NULL,
`pos_no` VARCHAR(20) NOT NULL,
`bill_no` VARCHAR(20) NOT NULL,
`product_cd` VARCHAR(20) NOT NULL,
`ca01` VARCHAR(50),
`ca02` VARCHAR(50),
`ca03` VARCHAR(50),
`product_name` VARCHAR(100),
`barcode` VARCHAR(20),
`amt` INT,
`qty` INT,
`tot_sale_amt` INT,
`dc_amt` INT,
`dcm_sale_amt` INT,
`net_amt` INT,
`vat_amt` INT,
`cash_receipt` INT,
`card` INT,
PRIMARY KEY (`sale_date`, `shop_name`, `pos_no`, `bill_no`, `product_cd`, `qty`), -- 옵션: 복합 PK (원하지 않으면 제거)
KEY `idx_sale_shop_pos_product` (`sale_date`, `shop_name`, `pos_no`, `product_cd`),
KEY `idx_category` (`ca01`, `ca02`, `ca03`),
KEY `idx_product_barcode` (`product_name`, `barcode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -1,113 +1,43 @@
# daily_run.py # ./lib/weather_asos.py
""" # ./lib/ga4.py
daily_run.py # ./lib/air_quality.py
# 각 파일을 모두 한번씩 실행
매일 정기적으로 실행되는 데이터 수집 스크립트 # daily_run.py
- weather_asos.py: 기상청 ASOS 데이터 수집
- ga4.py: Google Analytics 4 데이터 수집
- air_quality.py: 대기환경 API 데이터 수집
"""
import os import os
import sys import sys
import logging
import traceback
from datetime import datetime
# lib 디렉토리를 path에 추가 # lib 디렉토리를 path에 추가
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), 'lib'))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), 'lib')))
from conf import db, db_schema from conf import db, db_schema
from lib.common import setup_logging
# 로거 설정
logger = setup_logging('daily_run', 'INFO')
def run_weather(): def run_weather():
"""기상청 ASOS 데이터 수집"""
try: try:
logger.info("=" * 60)
logger.info("[RUNNING] weather_asos.py")
logger.info("=" * 60)
from weather_asos import main as weather_main from weather_asos import main as weather_main
result = weather_main() print("\n[RUNNING] weather_asos.py")
logger.info("[SUCCESS] weather_asos 완료") weather_main()
return True
except Exception as e: except Exception as e:
logger.error(f"[ERROR] weather_asos 실행 실패: {e}") print(f"[ERROR] weather_asos 실행 실패: {e}")
logger.error(traceback.format_exc())
return False
def run_ga4(): def run_ga4():
"""Google Analytics 4 데이터 수집"""
try: try:
logger.info("=" * 60)
logger.info("[RUNNING] ga4.py")
logger.info("=" * 60)
from ga4 import main as ga4_main from ga4 import main as ga4_main
result = ga4_main() print("\n[RUNNING] ga4.py")
logger.info("[SUCCESS] ga4 완료") ga4_main()
return True
except Exception as e: except Exception as e:
logger.error(f"[ERROR] ga4 실행 실패: {e}") print(f"[ERROR] ga4 실행 실패: {e}")
logger.error(traceback.format_exc())
return False
def run_air_quality(): def run_air_quality():
"""대기환경 API 데이터 수집"""
try: try:
logger.info("=" * 60)
logger.info("[RUNNING] air_quality.py")
logger.info("=" * 60)
from air_quality import AirQualityCollector from air_quality import AirQualityCollector
print("\n[RUNNING] air_quality.py")
config = db.load_config() config = db.load_config()
collector = AirQualityCollector(config, db.engine, db_schema.air) collector = AirQualityCollector(config, db.engine, db_schema.air)
collector.run() collector.run()
logger.info("[SUCCESS] air_quality 완료")
return True
except Exception as e: except Exception as e:
logger.error(f"[ERROR] air_quality 실행 실패: {e}") print(f"[ERROR] air_quality 실행 실패: {e}")
logger.error(traceback.format_exc())
return False
def main():
"""메인 실행 함수"""
logger.info(f"\n{'='*60}")
logger.info(f"[START] daily_run.py 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"{'='*60}\n")
results = {}
# 각 작업 실행
results['weather'] = run_weather()
results['ga4'] = run_ga4()
results['air_quality'] = run_air_quality()
# 결과 정리
logger.info(f"\n{'='*60}")
logger.info("[SUMMARY] 작업 완료 결과:")
logger.info(f"{'='*60}")
for task, success in results.items():
status = "✓ SUCCESS" if success else "✗ FAILED"
logger.info(f" {task}: {status}")
logger.info(f"[END] daily_run.py 종료: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"{'='*60}\n")
# 모든 작업 성공 여부 반환
return all(results.values())
if __name__ == "__main__": if __name__ == "__main__":
try: run_weather()
success = main() run_ga4()
sys.exit(0 if success else 1) run_air_quality()
except Exception as e:
logger.critical(f"[CRITICAL] 예상치 못한 에러: {e}")
logger.critical(traceback.format_exc())
sys.exit(1)

View File

@ -1,103 +0,0 @@
version: '3.8'
services:
# MariaDB 데이터베이스
mariadb:
image: mariadb:11.2-jammy
container_name: fg-static-db
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${DB_NAME:-firstgarden}
MYSQL_USER: ${DB_USER:-firstgarden}
MYSQL_PASSWORD: ${DB_PASSWORD:-Fg9576861!}
TZ: Asia/Seoul
volumes:
# 실제 볼륨 마운트 (바인드 마운트) - 데이터베이스 데이터 저장
- ./db_data:/var/lib/mysql
# 초기화 SQL
- ./conf/install.sql:/docker-entrypoint-initdb.d/init.sql
# 데이터베이스 백업 및 복구 폴더
- ./dbbackup:/dbbackup
ports:
- "${DB_PORT:-3306}:3306"
networks:
- static-network
restart: unless-stopped
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
timeout: 10s
interval: 30s
retries: 3
start_period: 40s
# 메인 데이터 수집 및 분석 서비스 (Flask 웹 서버 포함)
fg-static:
container_name: fg-static-app
build:
context: .
dockerfile: build/Dockerfile
image: reg.firstgarden.co.kr/fg-static:latest
depends_on:
mariadb:
condition: service_healthy
environment:
# 데이터베이스 설정
DB_HOST: mariadb
DB_PORT: 3306
DB_NAME: ${DB_NAME:-firstgarden}
DB_USER: ${DB_USER:-firstgarden}
DB_PASSWORD: ${DB_PASSWORD:-Fg9576861!}
# 타임존
TZ: Asia/Seoul
# Python 설정
PYTHONUNBUFFERED: 1
PYTHONDONTWRITEBYTECODE: 1
# 로깅 레벨
LOG_LEVEL: INFO
# Flask 설정
FLASK_ENV: production
FLASK_DEBUG: 0
volumes:
# 설정 파일
- ./conf:/app/conf:ro
- ./conf/config.yaml:/app/conf/config.yaml:ro
- ./conf/service-account-credentials.json:/app/conf/service-account-credentials.json:ro
# 실제 볼륨 마운트 (바인드 마운트)
- ./data:/app/data
- ./output:/app/output
- ./uploads:/app/uploads
- ./dbbackup:/app/dbbackup
- ./logs:/app/logs
ports:
# Flask 파일 업로드 서버 포트
- "8889:8889"
# 기타 포트 (필요 시)
- "5000:5000"
networks:
- static-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8889/api/status", "||", "exit", "1"]
timeout: 10s
interval: 60s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "10"
networks:
# 컨테이너 간 통신 네트워크
static-network:
driver: bridge
# 로그
logs_volume:
driver: local

View File

@ -1,17 +1,13 @@
import os, sys import os, sys
import requests import requests
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy import select, func, and_ from sqlalchemy import select, func, and_
from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from conf import db, db_schema 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: class AirQualityCollector:
def __init__(self, config, engine, table): def __init__(self, config, engine, table):
@ -24,24 +20,6 @@ class AirQualityCollector:
self.engine = engine self.engine = engine
self.table = table self.table = table
self.yesterday = (datetime.now() - timedelta(days=1)).date() 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): def get_latest_date(self, conn, station):
try: try:
@ -52,7 +30,6 @@ class AirQualityCollector:
return result return result
except Exception as e: except Exception as e:
print(f"[ERROR] 가장 최근 날짜 조회 실패: {e}") print(f"[ERROR] 가장 최근 날짜 조회 실패: {e}")
traceback.print_exc()
return None return None
def save_data_to_db(self, items, conn, station): def save_data_to_db(self, items, conn, station):
@ -60,7 +37,7 @@ class AirQualityCollector:
try: try:
item_date = datetime.strptime(item['msurDt'], '%Y-%m-%d').date() item_date = datetime.strptime(item['msurDt'], '%Y-%m-%d').date()
except Exception as e: except Exception as e:
print(f"[ERROR] 날짜 파싱 오류: {item.get('msurDt')} {e}") print(f"[ERROR] 날짜 파싱 오류: {item.get('msurDt')} {e}")
continue continue
data = { data = {
@ -76,7 +53,7 @@ class AirQualityCollector:
try: try:
if self.debug: if self.debug:
print(f"[DEBUG] {item_date} [{station}] DB 저장 시도: {data}") print(f"[DEBUG] {item_date} [{station}] DB 저장 시도: {data}")
continue continue
if self.force_update: if self.force_update:
@ -98,7 +75,6 @@ class AirQualityCollector:
print(f"[ERROR] DB 중복 오류: {e}") print(f"[ERROR] DB 중복 오류: {e}")
except Exception as e: except Exception as e:
print(f"[ERROR] DB 저장 실패: {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): 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" url = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg"
@ -112,39 +88,23 @@ class AirQualityCollector:
'msrstnName': station_name, 'msrstnName': station_name,
} }
resp = None
try: try:
resp = self.session.get(url, params=params, timeout=20) resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
return data.get('response', {}).get('body', {}).get('items', []) return data.get('response', {}).get('body', {}).get('items', [])
except Exception as e: except requests.RequestException as e:
body_preview = None print(f"[ERROR] 요청 실패: {e}")
try: return []
if resp is not None: except ValueError as e:
body_preview = resp.text[:1000] print(f"[ERROR] JSON 파싱 실패: {e}")
except Exception:
body_preview = None
print(f"[ERROR] 요청 실패: {e} status={getattr(resp, 'status_code', 'n/a')} body_preview={body_preview}")
traceback.print_exc()
return [] return []
def test_num_of_rows(self, station_name, date_str): 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 max_rows = 1000
min_rows = 100 min_rows = 100
while max_rows >= min_rows: while max_rows >= min_rows:
resp = None
try: try:
url = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg" url = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg"
params = { params = {
@ -156,25 +116,13 @@ class AirQualityCollector:
'inqEndDt': date_str, 'inqEndDt': date_str,
'msrstnName': station_name, 'msrstnName': station_name,
} }
resp = self.session.get(url, params=params, timeout=20) resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status() resp.raise_for_status()
resp.json() # 성공하면 해당 max_rows 사용 가능 resp.json()
print(f"[INFO] numOfRows 최대값 탐색 성공: {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 return max_rows
except Exception as e: except Exception as e:
body_preview = None print(f"[WARN] numOfRows={max_rows} 실패: {e}, 100 감소 후 재시도")
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 max_rows -= 100
print("[WARN] 최소 numOfRows 값(100)도 실패했습니다. 기본값 100 사용") print("[WARN] 최소 numOfRows 값(100)도 실패했습니다. 기본값 100 사용")

View File

@ -1,169 +1,10 @@
# common.py # common.py
import os import os, yaml
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 인스턴스
""" """
logger = logging.getLogger(name) path = os.path.join(os.path.dirname(__file__), '..', 'conf', 'config.yaml')
with open(path, encoding='utf-8') as f:
if not logger.handlers: return yaml.safe_load(f)
handler = logging.StreamHandler()
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(getattr(logging, level.upper(), logging.INFO))
return logger
def get_logger(name: str) -> logging.Logger:
"""기존 호환성 유지"""
return setup_logging(name)
def load_config(config_path: str = None) -> dict:
"""
환경변수에서 설정 로드 (config.yaml 대체)
Args:
config_path: 하위 호환성을 위해 유지 (사용 안 함)
Returns:
설정 딕셔너리 (환경변수 기반)
"""
config = {
'database': {
'host': os.getenv('DB_HOST', 'localhost'),
'user': os.getenv('DB_USER', 'firstgarden'),
'password': os.getenv('DB_PASSWORD', 'Fg9576861!'),
'name': os.getenv('DB_NAME', 'firstgarden')
},
'table_prefix': os.getenv('TABLE_PREFIX', 'fg_manager_static_'),
'DATA_API': {
'serviceKey': os.getenv('DATA_API_SERVICE_KEY', ''),
'startDt': os.getenv('DATA_API_START_DATE', '20170101'),
'endDt': os.getenv('DATA_API_END_DATE', '20250701'),
'air': {
'station_name': os.getenv('AIR_STATION_NAMES', '운정').split(',')
},
'weather': {
'stnIds': [int(x) for x in os.getenv('WEATHER_STN_IDS', '99').split(',')]
}
},
'ga4': {
'token': os.getenv('GA4_API_TOKEN', ''),
'property_id': int(os.getenv('GA4_PROPERTY_ID', '384052726')),
'service_account_file': os.getenv('GA4_SERVICE_ACCOUNT_FILE', './conf/service-account-credentials.json'),
'startDt': os.getenv('GA4_START_DATE', '20170101'),
'endDt': os.getenv('GA4_END_DATE', '20990731'),
'max_rows_per_request': int(os.getenv('GA4_MAX_ROWS_PER_REQUEST', '10000'))
},
'POS': {
'VISITOR_CA': os.getenv('VISITOR_CATEGORIES', '입장료,티켓,기업제휴').split(',')
},
'FORECAST_WEIGHT': {
'visitor_forecast_multiplier': float(os.getenv('FORECAST_VISITOR_MULTIPLIER', '0.5')),
'minTa': float(os.getenv('FORECAST_WEIGHT_MIN_TEMP', '1.0')),
'maxTa': float(os.getenv('FORECAST_WEIGHT_MAX_TEMP', '1.0')),
'sumRn': float(os.getenv('FORECAST_WEIGHT_PRECIPITATION', '10.0')),
'avgRhm': float(os.getenv('FORECAST_WEIGHT_HUMIDITY', '1.0')),
'pm25': float(os.getenv('FORECAST_WEIGHT_PM25', '1.0')),
'is_holiday': int(os.getenv('FORECAST_WEIGHT_HOLIDAY', '20'))
},
'max_workers': int(os.getenv('MAX_WORKERS', '4')),
'debug': os.getenv('DEBUG', 'false').lower() == 'true',
'force_update': os.getenv('FORCE_UPDATE', 'false').lower() == 'true',
'upsolution': {
'id': os.getenv('UPSOLUTION_ID', 'firstgarden'),
'code': os.getenv('UPSOLUTION_CODE', '1112'),
'pw': os.getenv('UPSOLUTION_PW', '9999')
}
}
return config
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(
f"파일 다운로드 대기 시간 초과 ({timeout}초): {download_dir}/*.{ext}"
)

View File

@ -1,117 +0,0 @@
import time
import os, sys
import threading
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from sqlalchemy import select, func
# 상위 경로를 sys.path에 추가해 프로젝트 내 모듈 임포트 가능하게 설정
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from conf import db, db_schema
# 처리 스크립트
import pos_update_bill
import pos_update_daily_product
# 데이터 폴더
DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../data'))
FILE_EXTENSIONS = ('.xls', '.xlsx')
BILL_PREFIX = "영수증별매출상세현황"
DAILY_PRODUCT_PREFIX = "일자별 (상품별)"
class NewFileHandler(FileSystemEventHandler):
def __init__(self):
super().__init__()
self._lock = threading.Lock()
self._processing_files = set()
def on_created(self, event):
if event.is_directory:
return
filepath = event.src_path
filename = os.path.basename(filepath)
if not filename.endswith(FILE_EXTENSIONS):
return
# 처리 대상 여부 확인
if filename.startswith(BILL_PREFIX) or filename.startswith(DAILY_PRODUCT_PREFIX):
print(f"[WATCHER] 신규 파일 감지: {filename}")
threading.Thread(target=self.process_file, args=(filepath, filename), daemon=True).start()
def process_file(self, filepath, filename):
with self._lock:
if filename in self._processing_files:
print(f"[WATCHER] {filename} 이미 처리 중")
return
self._processing_files.add(filename)
try:
time.sleep(3) # 파일 쓰기 완료 대기
print(f"[WATCHER] 파일 처리 시작: {filename}")
if filename.startswith(BILL_PREFIX):
pos_update_bill.main()
elif filename.startswith(DAILY_PRODUCT_PREFIX):
pos_update_daily_product.main()
else:
print(f"[WATCHER] 처리 대상이 아님: {filename}")
return
except Exception as e:
print(f"[WATCHER] 처리 중 오류 발생: {filename} / {e}")
else:
try:
os.remove(filepath)
print(f"[WATCHER] 파일 처리 완료 및 삭제: {filename}")
except Exception as e:
print(f"[WATCHER] 파일 삭제 실패: {filename} / {e}")
finally:
with self._lock:
self._processing_files.discard(filename)
def check_latest_dates():
"""pos 및 pos_billdata 테이블의 최신 일자 조회"""
try:
engine = db.engine
with engine.connect() as conn:
# pos 테이블
pos_latest = conn.execute(
select(func.max(db_schema.pos.c.date))
).scalar()
# pos_billdata 테이블
bill_latest = conn.execute(
select(func.max(db_schema.pos_billdata.c.sale_date))
).scalar()
print("============================================")
print("[DB] 최근 데이터 저장일")
print(f" - pos : {pos_latest if pos_latest else '데이터 없음'}")
print(f" - pos_billdata : {bill_latest if bill_latest else '데이터 없음'}")
print("============================================")
except Exception as e:
print(f"[DB] 최근 날짜 조회 중 오류 발생: {e}")
def start_watching():
print(f"[WATCHER] '{DATA_DIR}' 폴더 감시 시작")
event_handler = NewFileHandler()
observer = Observer()
observer.schedule(event_handler, DATA_DIR, recursive=False)
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("[WATCHER] 감시 종료 요청 수신, 종료 중...")
observer.stop()
observer.join()
if __name__ == "__main__":
check_latest_dates() # ✅ 감시 시작 전 DB의 최신 날짜 출력
start_watching()

View File

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

View File

@ -4,7 +4,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import yaml import yaml
import requests import requests
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import date, datetime, timedelta from datetime import datetime, date
from sqlalchemy import select, insert, delete from sqlalchemy import select, insert, delete
# config.yaml 경로 및 로딩 # config.yaml 경로 및 로딩
@ -134,40 +134,8 @@ def is_korean_holiday(dt: date) -> bool:
finally: finally:
session.close() session.close()
def get_holiday_dates(start_date: date, end_date: date) -> set[date]:
"""특정 기간 내의 휴일 목록 반환"""
session = db.get_session()
try:
stmt = select(holiday_table.c.date).where(
holiday_table.c.date.between(start_date.strftime("%Y%m%d"), end_date.strftime("%Y%m%d"))
)
results = session.execute(stmt).scalars().all()
return set(datetime.strptime(d, "%Y%m%d").date() for d in results)
finally:
session.close()
def get_weekday_dates(start_date: date, end_date: date) -> set[date]:
"""특정 기간 중 평일(월~금 & 비휴일) 목록 반환"""
holiday_dates = get_holiday_dates(start_date, end_date)
result = set()
curr = start_date
while curr <= end_date:
if curr.weekday() < 5 and curr not in holiday_dates: # 월(0)~금(4)
result.add(curr)
curr += timedelta(days=1)
return result
if __name__ == "__main__": if __name__ == "__main__":
print("📌 휴일 테스트 시작") print("📌 특일정보 초기화 시작")
init_holidays() init_holidays()
print("✅ 특일정보 초기화 완료")
from datetime import date
start = date(2025, 1, 1)
end = date(2025, 12, 31)
holidays = get_holiday_dates(start, end)
print(f"🔍 {start} ~ {end} 사이 휴일 {len(holidays)}")
for d in sorted(holidays):
print(" -", d)

View File

@ -1,100 +0,0 @@
# test.py
import os
import csv
import sys
from datetime import datetime
from sqlalchemy import select, and_
# 경로 설정
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from conf import db, db_schema
# CSV 파일명 설정
CSV_FILENAME = 'sample.csv' # <- 여기에 파일명을 입력
CSV_PATH = os.path.join(os.path.dirname(__file__), '../data', CSV_FILENAME)
# DB 설정
engine = db.engine
table = db_schema.fg_manager_static_pos
# 기본값
DEFAULT_VALUES = {
'ca01': '매표소',
'ca02': '기타',
'ca03': '입장료',
'barcode': 100000,
'name': '입장객',
'tot_amount': 0,
'tot_discount': 0,
'actual_amount': 0,
}
def load_csv(filepath):
rows = []
try:
with open(filepath, newline='', encoding='utf-8-sig') as csvfile: # utf-8-sig 로 BOM 제거
reader = csv.DictReader(csvfile)
for row in reader:
try:
date = datetime.strptime(row['date'], '%Y-%m-%d').date()
qty = int(float(row['qty'])) # 소수점 포함 숫자라도 정수로 변환
data = DEFAULT_VALUES.copy()
data['date'] = date
data['qty'] = qty
rows.append(data)
except Exception as e:
print(f"[WARN] 잘못된 행 건너뜀: {row} / 오류: {e}")
except FileNotFoundError:
print(f"[ERROR] 파일이 존재하지 않음: {filepath}")
sys.exit(1)
return rows
def check_existing(session, row):
stmt = select(table.c.idx).where(
and_(
table.c.date == row['date'],
table.c.ca01 == row['ca01'],
table.c.ca02 == row['ca02'],
table.c.ca03 == row['ca03'],
table.c.barcode == row['barcode'],
table.c.name == row['name']
)
)
return session.execute(stmt).scalar() is not None
def insert_rows(rows):
inserted = 0
session = db.get_session()
try:
for i, row in enumerate(rows, 1):
if check_existing(session, row):
print(f"[SKIP] {i}행: 이미 존재함 ({row['date']}, qty={row['qty']})")
continue
session.execute(table.insert().values(**row))
inserted += 1
session.commit()
except Exception as e:
session.rollback()
print(f"[ERROR] 삽입 중 오류 발생: {e}")
finally:
session.close()
return inserted
def main():
print(f"[INFO] CSV 파일 로드 중: {CSV_PATH}")
rows = load_csv(CSV_PATH)
print(f"[INFO] 총 데이터 건수: {len(rows)}")
if not rows:
print("[WARN] 삽입할 데이터가 없습니다.")
return
inserted = insert_rows(rows)
print(f"[DONE] DB 삽입 완료: {inserted}건 / 전체 {len(rows)}건 중")
if __name__ == "__main__":
main()

View File

@ -1,263 +0,0 @@
"""
영수증별매출상세현황 엑셀파일을 기반으로 MariaDB에 데이터 업데이트
1. 파일은 ./data 폴더에 위치 (파일명: '영수증별매출상세현황*.xls[x]')
2. 중복된 데이터는 update 처리됨 (on duplicate key update)
3. 처리 후 파일 자동 삭제 (파일 삭제 로직은 필요시 추가 가능)
"""
import os
import sys
import re
import pandas as pd
from datetime import datetime
from sqlalchemy.dialects.mysql import insert
from sqlalchemy import select
# 상위 경로를 sys.path에 추가해 프로젝트 내 모듈 임포트 가능하게 설정
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from conf import db, db_schema
from lib.common import load_config
# 설정 파일 로드 및 데이터 폴더 경로 설정
CONFIG = load_config()
DATA_DIR = os.path.join(os.path.dirname(__file__), '../data')
# 처리 대상 파일명 패턴: '영수증별매출상세현황'으로 시작하고 .xls 또는 .xlsx 확장자
FILE_PATTERN = re.compile(r"^영수증별매출상세현황.*\.xls[x]?$")
# 엑셀 상단 A3셀 형식 예: "조회일자 : 2025-07-27 매장선택 : [V83728] 퍼스트(삐아또"
HEADER_PATTERN = re.compile(r"조회일자\s*:\s*(\d{4}-\d{2}-\d{2})\s+매장선택\s*:\s*\[(\w+)]\s*(.+)")
def extract_file_info(filepath: str):
"""
엑셀 파일 상단에서 조회일자, 매장코드, 매장명을 추출한다.
A3 셀 (2행 0열, 0부터 시작 기준) 데이터를 정규식으로 파싱.
Args:
filepath (str): 엑셀파일 경로
Returns:
tuple: (sale_date: date, shop_cd: str, shop_name: str)
Raises:
ValueError: 정규식 매칭 실패 시
"""
print(f"[INFO] {filepath} 상단 조회일자 및 매장 정보 추출 시작")
df_head = pd.read_excel(filepath, header=None, nrows=5)
first_row = df_head.iloc[2, 0] # 3행 A열 (0-based index)
match = HEADER_PATTERN.search(str(first_row))
if not match:
raise ValueError(f"[ERROR] 조회일자 및 매장 정보 추출 실패: {filepath}")
sale_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
shop_cd = match.group(2)
shop_name = match.group(3).strip()
print(f"[INFO] 추출된 조회일자: {sale_date}, 매장코드: {shop_cd}, 매장명: {shop_name}")
return sale_date, shop_cd, shop_name
def load_excel_data(filepath: str):
"""
지정한 컬럼만 읽고, 헤더는 6번째 행(0-based index 5)으로 지정.
'합계'라는 단어가 '포스번호' 컬럼에 있으면 그 행부터 제거한다.
Args:
filepath (str): 엑셀파일 경로
Returns:
pd.DataFrame: 전처리된 데이터프레임
Raises:
ValueError: 필수 컬럼 누락 시
"""
print(f"[INFO] {filepath} 데이터 영역 로드 시작")
usecols = [
"포스번호", "영수증번호", "구분", "테이블명", "최초주문", "결제시각",
"상품코드", "바코드", "상품명", "수량", "총매출액", "ERP 매핑코드",
"비고", "할인액", "할인구분", "실매출액", "가액", "부가세"
]
# header=5 => 6번째 행이 컬럼명
df = pd.read_excel(filepath, header=5, dtype=str)
# 컬럼명 좌우 공백 제거
df.columns = df.columns.str.strip()
# '합계'인 행의 인덱스 찾기 및 제거
if '합계' in df['포스번호'].values:
idx = df[df['포스번호'] == '합계'].index[0]
df = df.loc[:idx-1]
print(f"[INFO] '합계' 행 이후 데이터 제거: {idx}번째 행부터 제외")
# 필수 컬럼 존재 여부 체크
if not set(usecols).issubset(df.columns):
raise ValueError(f"[ERROR] 필수 컬럼 누락: 현재 컬럼 {df.columns.tolist()}")
df = df[usecols]
print(f"[INFO] {filepath} 데이터 영역 로드 완료, 데이터 건수: {len(df)}")
return df
def normalize_data(df: pd.DataFrame, sale_date, shop_cd):
"""
컬럼명을 내부 규칙에 맞게 변경하고, 숫자 필드를 정수형으로 변환한다.
조회일자와 매장코드를 데이터프레임에 추가.
Args:
df (pd.DataFrame): 원본 데이터프레임
sale_date (date): 조회일자
shop_cd (str): 매장코드
Returns:
pd.DataFrame: 정규화된 데이터프레임
"""
print(f"[INFO] 데이터 정규화 시작")
def to_int(x):
try:
return int(str(x).replace(",", "").strip())
except:
return 0
df.rename(columns={
"포스번호": "pos_no",
"영수증번호": "bill_no",
"구분": "division",
"테이블명": "table_no",
"최초주문": "order_time",
"결제시각": "pay_time",
"상품코드": "product_cd",
"바코드": "barcode",
"상품명": "product_name",
"수량": "qty",
"총매출액": "tot_sale_amt",
"ERP 매핑코드": "erp_cd",
"비고": "remark",
"할인액": "dc_amt",
"할인구분": "dc_type",
"실매출액": "dcm_sale_amt",
"가액": "net_amt",
"부가세": "vat_amt"
}, inplace=True)
df["sale_date"] = sale_date
df["shop_cd"] = shop_cd
# 숫자형 컬럼 정수 변환
int_fields = ["qty", "tot_sale_amt", "dc_amt", "dcm_sale_amt", "net_amt", "vat_amt"]
for field in int_fields:
df[field] = df[field].apply(to_int)
# pos_no, bill_no는 반드시 int로 변환
df["pos_no"] = df["pos_no"].astype(int)
df["bill_no"] = df["bill_no"].astype(int)
print(f"[INFO] 데이터 정규화 완료")
return df
def upsert_data(df: pd.DataFrame, batch_size: int = 500) -> int:
"""
SQLAlchemy insert 구문을 사용하여
중복 PK 발생 시 update 처리 (on duplicate key update)
대량 데이터는 batch_size 단위로 나누어 처리
Args:
df (pd.DataFrame): DB에 삽입할 데이터
batch_size (int): 한번에 처리할 데이터 건수 (기본 500)
Returns:
int: 영향 받은 총 행 수
"""
print(f"[INFO] DB 저장 시작")
df = df.where(pd.notnull(df), None) # NaN → None 변환
engine = db.get_engine()
metadata = db_schema.metadata
table = db_schema.pos_billdata
total_affected = 0
with engine.connect() as conn:
for start in range(0, len(df), batch_size):
batch_df = df.iloc[start:start+batch_size]
records = batch_df.to_dict(orient="records")
insert_stmt = insert(table).values(records)
update_fields = {
col.name: insert_stmt.inserted[col.name]
for col in table.columns
if col.name not in table.primary_key.columns
}
upsert_stmt = insert_stmt.on_duplicate_key_update(update_fields)
try:
result = conn.execute(upsert_stmt)
conn.commit()
total_affected += result.rowcount
print(f"[INFO] 배치 처리 완료: {start} ~ {start+len(records)-1} / 영향 행 수: {result.rowcount}")
except Exception as e:
print(f"[ERROR] 배치 처리 실패: {start} ~ {start+len(records)-1} / 오류: {e}")
# 필요 시 raise 하거나 continue로 다음 배치 진행 가능
raise
print(f"[INFO] DB 저장 전체 완료, 총 영향 행 수: {total_affected}")
return total_affected
def ensure_shop_exists(shop_cd, shop_name):
"""
매장 정보 테이블에 매장코드가 없으면 신규 등록한다.
Args:
shop_cd (str): 매장 코드
shop_name (str): 매장 명
"""
print(f"[INFO] 매장 존재 여부 확인: {shop_cd}")
engine = db.get_engine()
conn = engine.connect()
shop_table = db_schema.pos_shop_name
try:
query = shop_table.select().where(shop_table.c.shop_cd == shop_cd)
result = conn.execute(query).fetchone()
if result is None:
print(f"[INFO] 신규 매장 등록: {shop_cd} / {shop_name}")
ins = shop_table.insert().values(shop_cd=shop_cd, shop_name=shop_name)
conn.execute(ins)
conn.commit()
else:
print(f"[INFO] 기존 매장 존재: {shop_cd}")
except Exception as e:
print(f"[ERROR] 매장 확인/등록 실패: {e}")
raise
finally:
conn.close()
def main():
"""
대상 데이터 파일 목록을 찾고, 파일별로 처리 진행한다.
처리 성공 시 저장 건수를 출력하고, 실패 시 오류 메시지 출력.
"""
files = [f for f in os.listdir(DATA_DIR) if FILE_PATTERN.match(f)]
print(f"[INFO] 발견된 파일 {len(files)}")
for file in files:
filepath = os.path.join(DATA_DIR, file)
print(f"[INFO] 파일: {file} 처리 시작")
try:
sale_date, shop_cd, shop_name = extract_file_info(filepath)
ensure_shop_exists(shop_cd, shop_name)
raw_df = load_excel_data(filepath)
df = normalize_data(raw_df, sale_date, shop_cd)
affected = upsert_data(df)
print(f"[DONE] 처리 완료: {file} / 저장 건수: {affected}")
# 처리 완료 후 파일 삭제 (필요 시 활성화)
# os.remove(filepath)
# print(f"[INFO] 처리 완료 후 파일 삭제: {file}")
except Exception as e:
print(f"[ERROR] {file} 처리 실패: {e}")
if __name__ == "__main__":
main()

View File

@ -1,16 +1,22 @@
# POS Update # POS Update
''' '''
포스 데이터를 추출한 엑셀파일을 업데이트
OK포스 > 매출관리 > 일자별 > 상품별 > 날짜 지정 > 조회줄수 5000으로 변경 > 엑셀 OK포스 > 매출관리 > 일자별 > 상품별 > 날짜 지정 > 조회줄수 5000으로 변경 > 엑셀
추출파일을 ./data에 복사 추출파일을 ./data에 복사
파일 실행하면 자동으로 mariadb의 DB에 삽입함. 파일 실행하면 자동으로 mariadb의 DB에 삽입함.
''' '''
import sys, os import sys, os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import tkinter as tk
import pandas as pd import pandas as pd
from tkinter import filedialog, messagebox
from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from conf import db, db_schema from conf import db, db_schema
from lib.common import load_config from lib.common import load_config
@ -18,25 +24,19 @@ CONFIG = load_config()
DATA_DIR = os.path.join(os.path.dirname(__file__), '../data') DATA_DIR = os.path.join(os.path.dirname(__file__), '../data')
def update_pos_table(engine, table, df): def update_pos_table(engine, table, df):
"""
데이터프레임을 테이블에 업데이트
Args:
engine: SQLAlchemy 엔진
table: DB 테이블 객체
df: 데이터프레임
"""
with engine.begin() as conn: with engine.begin() as conn:
for idx, row in df.iterrows(): for idx, row in df.iterrows():
data = row.to_dict() data = row.to_dict()
stmt = mysql_insert(table).values(**data) stmt = mysql_insert(table).values(**data)
# insert ... on duplicate key update (복합 unique key 기준)
update_data = { update_data = {
'qty': data['qty'], 'qty': data['qty'],
'tot_amount': data['tot_amount'], 'tot_amount': data['tot_amount'],
'tot_discount': data['tot_discount'], 'tot_discount': data['tot_discount'],
'actual_amount': data['actual_amount'] 'actual_amount': data['actual_amount']
} }
stmt = stmt.on_duplicate_key_update(**update_data) stmt = stmt.on_duplicate_key_update(**update_data)
try: try:
@ -47,20 +47,10 @@ def update_pos_table(engine, table, df):
print("[DONE] 모든 데이터 삽입 완료") print("[DONE] 모든 데이터 삽입 완료")
def process_file(filepath, table, engine): def process_file(filepath, table, engine):
"""
OKPOS 파일 처리
Args:
filepath: 파일 경로
table: DB 테이블
engine: SQLAlchemy 엔진
Returns:
tuple[bool, int]: (성공 여부, )
"""
print(f"[INFO] 처리 시작: {filepath}") print(f"[INFO] 처리 시작: {filepath}")
try: try:
ext = os.path.splitext(filepath)[-1].lower() ext = os.path.splitext(filepath)[-1].lower()
if ext == ".xls": if ext == ".xls":
df = pd.read_excel(filepath, header=5, engine="xlrd") df = pd.read_excel(filepath, header=5, engine="xlrd")
elif ext == ".xlsx": elif ext == ".xlsx":
@ -83,7 +73,8 @@ def process_file(filepath, table, engine):
'실매출액': 'actual_amount' '실매출액': 'actual_amount'
}, inplace=True) }, inplace=True)
df.drop(columns=[col for col in ['idx'] if col in df.columns], inplace=True) if 'idx' in df.columns:
df = df.drop(columns=['idx'])
df['date'] = pd.to_datetime(df['date']).dt.date df['date'] = pd.to_datetime(df['date']).dt.date
df['barcode'] = df['barcode'].astype(int) df['barcode'] = df['barcode'].astype(int)
@ -105,79 +96,54 @@ def process_file(filepath, table, engine):
print(f"[INFO] 처리 완료: {filepath}") print(f"[INFO] 처리 완료: {filepath}")
return True, len(df) 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): def batch_process_files(table, engine):
files = [f for f in os.listdir(DATA_DIR) if f.startswith("일자별 (상품별)") and f.endswith(('.xlsx', '.xls'))] files = [f for f in os.listdir(DATA_DIR) if f.startswith("일자별 (상품별)") and f.endswith(('.xlsx', '.xls'))]
if not files: if not files:
print("[INFO] 처리할 파일이 없습니다.") print("[INFO] 처리할 파일이 없습니다.")
return False return False
print(f"[INFO] {len(files)}개의 파일을 찾았습니다.") print(f"[INFO] {len(files)}개의 파일을 찾았습니다.")
total_rows = 0 total_rows = 0
# deleted_files = 0 deleted_files = 0
for fname in files: for fname in files:
full_path = os.path.join(DATA_DIR, fname) full_path = os.path.join(DATA_DIR, fname)
success, count = process_file(full_path, table, engine) success, count = process_file(full_path, table, engine)
if success: if success:
total_rows += count total_rows += count
"""
try: try:
os.remove(full_path) os.remove(full_path)
print(f"[INFO] 파일 삭제 완료: {fname}") print(f"[INFO] 처리 완료 후 파일 삭제: {fname}")
deleted_files += 1 deleted_files += 1
except Exception as e: except Exception as e:
print(f"[WARN] 파일 삭제 실패: {fname} / {e}") print(f"[WARN] 파일 삭제 실패: {fname}, {e}")
"""
print(f"[INFO] 처리 데이터 건수: {total_rows}") print(f"[INFO] 처리된 전체 데이터 건수: {total_rows}")
# print(f"[INFO] 삭제된 파일 수: {deleted_files}") print(f"[INFO] 삭제된 파일 수: {deleted_files}")
return True return True
def run_pos_update():
filepath = filedialog.askopenfilename(
filetypes=[("Excel Files", "*.xlsx *.xls")],
title="파일을 선택하세요"
)
if not filepath:
return
engine = db.engine
try:
table = db_schema.pos
except AttributeError:
messagebox.showerror("DB 오류", "'pos' 테이블이 db_schema에 정의되어 있지 않습니다.")
return
if messagebox.askyesno("확인", f"'{os.path.basename(filepath)}' 파일을 'pos' 테이블에 업로드 하시겠습니까?"):
success, count = process_file(filepath, table, engine)
if success:
print(f"[INFO] 수동 선택된 파일 처리 완료: {count}")
messagebox.showinfo("완료", f"DB 업데이트가 완료되었습니다.\n{count}건 처리됨.")
def main(): def main():
engine = db.engine engine = db.engine
try: try:
@ -188,7 +154,18 @@ def main():
batch_done = batch_process_files(table, engine) batch_done = batch_process_files(table, engine)
if not batch_done: if not batch_done:
print("[INFO] 처리할 데이터가 없습니다.") # GUI 시작
root = tk.Tk()
root.title("POS 데이터 업데이트")
root.geometry("300x150")
lbl = tk.Label(root, text="POS 데이터 업데이트")
lbl.pack(pady=20)
btn = tk.Button(root, text="데이터 선택 및 업데이트", command=run_pos_update)
btn.pack()
root.mainloop()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,234 +0,0 @@
import os
import sys
import pandas as pd
import shutil
import threading
from queue import Queue
from sqlalchemy import Table, MetaData
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.exc import SQLAlchemyError
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from lib.common import get_logger
from conf import db, db_schema # get_engine, get_session 포함
logger = get_logger("POS_UPS")
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 nan_to_none(value):
import pandas as pd
if pd.isna(value):
return None
return value
def load_excel_data(filepath: str):
df = pd.read_excel(filepath, header=1) # 2행이 header, 3행부터 데이터
df.columns = [col.strip() for col in df.columns]
required_cols = ['영수증 번호', '품목명']
for col in required_cols:
if col not in df.columns:
raise ValueError(f"필수 컬럼 누락: {col}")
df = df.dropna(subset=required_cols)
return df
def prepare_bulk_data(df):
bulk_data = []
for idx, row in df.iterrows():
try:
data = {
"sale_date": pd.to_datetime(row["매출일시"]),
"shop_name": str(row["매장명"]).strip(),
"pos_no": str(row["포스"]).strip(),
"bill_no": str(row["영수증 번호"]).strip(),
"product_cd": str(row["품목"]).strip(),
"product_name": str(row["품목명"]).strip(),
"qty": int(row["수량"]),
"ca01": nan_to_none(row.get("대분류", None)),
"ca02": nan_to_none(row.get("중분류", None)),
"ca03": nan_to_none(row.get("소분류", None)),
"barcode": nan_to_none(row.get("바코드", None)),
"amt": int(row.get("단가", 0)),
"tot_sale_amt": int(row.get("주문 금액", 0)),
"dc_amt": int(row.get("할인 금액", 0)),
"dcm_sale_amt": int(row.get("공급가액", 0)),
"vat_amt": int(row.get("세금", 0)),
"net_amt": int(row.get("결제 금액", 0)),
"cash_receipt": int(row.get("현금영수증", 0)),
"card": int(row.get("카드", 0)),
}
bulk_data.append(data)
except Exception as e:
logger.warning(f"[ERROR:ROW] 데이터 생성 실패: {e} / 인덱스: {idx}")
return bulk_data
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 0
total = len(bulk_data)
inserted_total = 0
for start in range(0, total, batch_size):
batch = bulk_data[start:start+batch_size]
insert_stmt = mysql_insert(table).values(batch)
update_cols = {c.name: insert_stmt.inserted[c.name] for c in table.columns
if c.name not in ['sale_date', 'shop_name', 'pos_no', 'bill_no', 'product_cd']}
upsert_stmt = insert_stmt.on_duplicate_key_update(update_cols)
try:
result = session.execute(upsert_stmt)
session.commit()
inserted_total += len(batch)
logger.info(f"[PROGRESS] {inserted_total} / {total} 건 처리 완료")
except SQLAlchemyError as e:
session.rollback()
logger.error(f"[FAIL] batch upsert 실패: {e}")
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)} 읽기 시작")
df = load_excel_data(filepath)
logger.info(f"[READ] {os.path.basename(filepath)} 읽기 완료 - {len(df)}")
bulk_data = prepare_bulk_data(df)
queue.put((filepath, bulk_data))
except Exception as e:
logger.error(f"[FAIL] {os.path.basename(filepath)} 읽기 실패 - {e}")
queue.put(None) # 종료 신호
def db_writer(queue, session, table):
"""DB 쓰기 스레드"""
while True:
item = queue.get()
if item is None:
break
filepath, bulk_data = item
logger.info(f"[START] {os.path.basename(filepath)} DB 삽입 시작")
try:
process_bulk_upsert(bulk_data, session, table)
dest = os.path.join(FINISH_DIR, os.path.basename(filepath))
shutil.move(filepath, dest)
logger.info(f"[MOVE] 완료: {dest}")
except Exception as e:
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()
metadata = MetaData()
table = Table(
db_schema.get_full_table_name("pos_ups_billdata"),
metadata,
autoload_with=engine
)
files = [os.path.join(DATA_DIR, f) for f in os.listdir(DATA_DIR)
if f.endswith(".xlsx") and f.startswith("영수증별 상세매출")]
logger.info(f"[INFO] 처리할 파일 {len(files)}")
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))
reader_thread.start()
writer_thread.start()
reader_thread.join()
writer_thread.join()
if __name__ == "__main__":
main()

View File

@ -9,13 +9,12 @@ from tkcalendar import DateEntry
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy import select, func, between from sqlalchemy import select, func, between
from conf import db_schema, db from conf import db_schema, db
from lib import holiday # 휴일 기능
# Windows DPI Awareness 설정 # Windows DPI Awareness 설정 (윈도우 전용)
if sys.platform == "win32": if sys.platform == "win32":
import ctypes import ctypes
try: try:
ctypes.windll.shcore.SetProcessDpiAwareness(1) ctypes.windll.shcore.SetProcessDpiAwareness(1) # SYSTEM_AWARE = 1
except Exception: except Exception:
pass pass
@ -27,23 +26,30 @@ class PosViewGUI(ctk.CTk):
super().__init__() super().__init__()
self.title("POS 데이터 조회") self.title("POS 데이터 조회")
self.geometry("1100x700") self.geometry("900x500")
self.configure(fg_color="#f0f0f0") self.configure(fg_color="#f0f0f0") # 배경색 맞춤
ctk.set_appearance_mode("light") ctk.set_appearance_mode("light")
ctk.set_default_color_theme("blue") ctk.set_default_color_theme("blue")
# 폰트 세팅 - NanumGothic이 없으면 Arial 대체
try: try:
self.label_font = ("NanumGothic", 13) self.label_font = ("NanumGothic", 13)
except Exception: except Exception:
self.label_font = ("Arial", 13) self.label_font = ("Arial", 13)
# Treeview 스타일 설정 (ttk 스타일)
style = ttk.Style(self) style = ttk.Style(self)
style.theme_use('default') style.theme_use('default')
style.configure("Treeview", font=("NanumGothic", 12), rowheight=30) style.configure("Treeview",
style.configure("Treeview.Heading", font=("NanumGothic", 13, "bold")) font=("NanumGothic", 12),
rowheight=30) # 높이 조절로 글씨 깨짐 방지
style.configure("Treeview.Heading",
font=("NanumGothic", 13, "bold"))
# 날짜 필터 # --- 위젯 배치 ---
# 날짜 범위
ctk.CTkLabel(self, text="시작일:", anchor="w", font=self.label_font, fg_color="#f0f0f0")\ ctk.CTkLabel(self, text="시작일:", anchor="w", font=self.label_font, fg_color="#f0f0f0")\
.grid(row=0, column=0, padx=10, pady=5, sticky="e") .grid(row=0, column=0, padx=10, pady=5, sticky="e")
self.start_date_entry = DateEntry(self, width=12, background='darkblue', foreground='white') self.start_date_entry = DateEntry(self, width=12, background='darkblue', foreground='white')
@ -54,18 +60,6 @@ class PosViewGUI(ctk.CTk):
self.end_date_entry = DateEntry(self, width=12, background='darkblue', foreground='white') self.end_date_entry = DateEntry(self, width=12, background='darkblue', foreground='white')
self.end_date_entry.grid(row=0, column=3, padx=10, pady=5, sticky="w") self.end_date_entry.grid(row=0, column=3, padx=10, pady=5, sticky="w")
# 날짜유형 라디오버튼
self.date_filter_var = ctk.StringVar(value="전체")
ctk.CTkLabel(self, text="날짜유형:", font=self.label_font, fg_color="#f0f0f0")\
.grid(row=0, column=4, padx=(10, 0), pady=5, sticky="e")
ctk.CTkRadioButton(self, text="전체", variable=self.date_filter_var, value="전체")\
.grid(row=0, column=5, padx=2, pady=5, sticky="w")
ctk.CTkRadioButton(self, text="휴일", variable=self.date_filter_var, value="휴일")\
.grid(row=0, column=6, padx=2, pady=5, sticky="w")
ctk.CTkRadioButton(self, text="평일", variable=self.date_filter_var, value="평일")\
.grid(row=0, column=7, padx=2, pady=5, sticky="w")
# 대분류 # 대분류
ctk.CTkLabel(self, text="대분류 :", anchor="w", font=self.label_font, fg_color="#f0f0f0")\ ctk.CTkLabel(self, text="대분류 :", anchor="w", font=self.label_font, fg_color="#f0f0f0")\
.grid(row=1, column=0, padx=10, pady=5, sticky="e") .grid(row=1, column=0, padx=10, pady=5, sticky="e")
@ -88,9 +82,9 @@ class PosViewGUI(ctk.CTk):
# 조회 버튼 # 조회 버튼
self.search_btn = ctk.CTkButton(self, text="조회", command=self.search, self.search_btn = ctk.CTkButton(self, text="조회", command=self.search,
fg_color="#0d6efd", hover_color="#0b5ed7", text_color="white") fg_color="#0d6efd", hover_color="#0b5ed7", text_color="white")
self.search_btn.grid(row=3, column=0, columnspan=8, pady=10) self.search_btn.grid(row=3, column=0, columnspan=4, pady=10)
# 상품별 트리뷰 # 결과 Treeview
self.DISPLAY_COLUMNS = ['ca01', 'ca02', 'ca03', 'name', 'qty', 'tot_amount', 'tot_discount', 'actual_amount'] self.DISPLAY_COLUMNS = ['ca01', 'ca02', 'ca03', 'name', 'qty', 'tot_amount', 'tot_discount', 'actual_amount']
self.COLUMN_LABELS = { self.COLUMN_LABELS = {
'ca01': '대분류', 'ca01': '대분류',
@ -103,38 +97,28 @@ class PosViewGUI(ctk.CTk):
'actual_amount': '실매출액' 'actual_amount': '실매출액'
} }
self.tree = ttk.Treeview(self, columns=self.DISPLAY_COLUMNS, show='headings', height=12) self.tree = ttk.Treeview(self, columns=self.DISPLAY_COLUMNS, show='headings', height=15)
for col in self.DISPLAY_COLUMNS: for col in self.DISPLAY_COLUMNS:
self.tree.heading(col, text=self.COLUMN_LABELS[col]) self.tree.heading(col, text=self.COLUMN_LABELS[col])
self.tree.column(col, width=120, anchor='center') self.tree.column(col, width=120, anchor='center')
self.tree.grid(row=4, column=0, columnspan=8, padx=10, pady=10, sticky="nsew") self.tree.grid(row=4, column=0, columnspan=4, padx=10, pady=10, sticky="nsew")
# 날짜 요약 트리뷰
self.date_tree = ttk.Treeview(self, columns=['date', 'qty', 'tot_amount', 'actual_amount'], show='headings', height=6)
self.date_tree.heading('date', text='일자')
self.date_tree.heading('qty', text='수량합')
self.date_tree.heading('tot_amount', text='총매출합')
self.date_tree.heading('actual_amount', text='실매출합')
for col in ['date', 'qty', 'tot_amount', 'actual_amount']:
self.date_tree.column(col, width=150, anchor='center')
self.date_tree.grid(row=5, column=0, columnspan=8, padx=10, pady=(0, 10), sticky="nsew")
# 그리드 가중치 설정 (창 크기에 따라 트리뷰 확장)
self.grid_rowconfigure(4, weight=1) self.grid_rowconfigure(4, weight=1)
self.grid_rowconfigure(5, weight=1) for col_index in range(4):
for col_index in range(8):
self.grid_columnconfigure(col_index, weight=1) self.grid_columnconfigure(col_index, weight=1)
# 날짜 기본값 # 날짜 기본값 설정 (전날부터 7일 전까지)
end_date = datetime.today().date() - timedelta(days=1) end_date = datetime.today().date() - timedelta(days=1)
start_date = end_date - timedelta(days=6) start_date = end_date - timedelta(days=6)
self.start_date_entry.set_date(start_date) self.start_date_entry.set_date(start_date)
self.end_date_entry.set_date(end_date) self.end_date_entry.set_date(end_date)
# 초기 대분류, 소분류 콤보박스 값 불러오기
self.load_ca01_options() self.load_ca01_options()
def on_ca01_selected(self, value): def on_ca01_selected(self, value):
# print("대분류 선택됨:", value) 디버깅용
self.load_ca03_options() self.load_ca03_options()
def load_ca01_options(self): def load_ca01_options(self):
@ -164,7 +148,7 @@ class PosViewGUI(ctk.CTk):
result = conn.execute(stmt) result = conn.execute(stmt)
ca03_list = [row[0] for row in result.fetchall()] ca03_list = [row[0] for row in result.fetchall()]
self.ca03_combo.configure(values=['전체'] + ca03_list) self.ca03_combo.configure(values=['전체'] + ca03_list)
self.ca03_combo.set('전체') self.ca03_combo.set('전체') # 항상 기본값으로 초기화
def search(self): def search(self):
start_date = self.start_date_entry.get_date() start_date = self.start_date_entry.get_date()
@ -172,34 +156,8 @@ class PosViewGUI(ctk.CTk):
ca01_val = self.ca01_combo.get() ca01_val = self.ca01_combo.get()
ca03_val = self.ca03_combo.get() ca03_val = self.ca03_combo.get()
name_val = self.name_entry.get().strip() name_val = self.name_entry.get().strip()
date_filter = self.date_filter_var.get()
print("🔍 date_filter:", date_filter,
"| start:", start_date, "end:", end_date)
if date_filter == "휴일":
valid_dates = holiday.get_holiday_dates(start_date, end_date)
print("🚩 반환된 휴일 날짜 리스트:", valid_dates)
conditions = []
if date_filter == "전체":
conditions.append(between(pos_table.c.date, start_date, end_date))
else:
if date_filter == "휴일":
valid_dates = holiday.get_holiday_dates(start_date, end_date)
elif date_filter == "평일":
valid_dates = holiday.get_weekday_dates(start_date, end_date)
else:
valid_dates = set()
if not valid_dates:
messagebox.showinfo("알림", f"{date_filter}에 해당하는 데이터가 없습니다.")
self.tree.delete(*self.tree.get_children())
self.date_tree.delete(*self.date_tree.get_children())
return
conditions.append(pos_table.c.date.in_(valid_dates))
conditions = [between(pos_table.c.date, start_date, end_date)]
if ca01_val != '전체': if ca01_val != '전체':
conditions.append(pos_table.c.ca01 == ca01_val) conditions.append(pos_table.c.ca01 == ca01_val)
if ca03_val != '전체': if ca03_val != '전체':
@ -208,7 +166,6 @@ class PosViewGUI(ctk.CTk):
conditions.append(pos_table.c.name.like(f"%{name_val}%")) conditions.append(pos_table.c.name.like(f"%{name_val}%"))
with engine.connect() as conn: with engine.connect() as conn:
# 상품별
stmt = select( stmt = select(
pos_table.c.ca01, pos_table.c.ca01,
pos_table.c.ca02, pos_table.c.ca02,
@ -222,42 +179,11 @@ class PosViewGUI(ctk.CTk):
result = conn.execute(stmt).mappings().all() result = conn.execute(stmt).mappings().all()
# 날짜별 요약
date_stmt = select(
pos_table.c.date,
func.sum(pos_table.c.qty).label("qty"),
func.sum(pos_table.c.tot_amount).label("tot_amount"),
func.sum(pos_table.c.actual_amount).label("actual_amount")
).where(*conditions).group_by(pos_table.c.date).order_by(pos_table.c.date)
date_summary = conn.execute(date_stmt).mappings().all()
# 트리뷰 초기화
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
self.date_tree.delete(*self.date_tree.get_children())
# 상품별 출력
for row in result: for row in result:
values = tuple(row[col] for col in self.DISPLAY_COLUMNS) values = tuple(row[col] for col in self.DISPLAY_COLUMNS)
self.tree.insert('', 'end', values=values) self.tree.insert('', 'end', values=values)
# 날짜별 출력
total_qty = total_amount = total_actual = 0
for row in date_summary:
self.date_tree.insert('', 'end', values=(
row['date'].strftime("%Y-%m-%d"),
row['qty'],
row['tot_amount'],
row['actual_amount']
))
total_qty += row['qty']
total_amount += row['tot_amount']
total_actual += row['actual_amount']
# 총합계 추가
self.date_tree.insert('', 'end', values=("총합계", total_qty, total_amount, total_actual))
if __name__ == "__main__": if __name__ == "__main__":
try: try:
import tkcalendar import tkcalendar

View File

@ -1,21 +0,0 @@
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

View File

@ -1,44 +0,0 @@
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__), '..'))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import yaml import yaml
@ -6,10 +6,8 @@ import requests
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy import select from sqlalchemy import select
import traceback
from conf import db, db_schema 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") CONFIG_PATH = os.path.join(os.path.dirname(__file__), "../conf/config.yaml")
@ -26,9 +24,7 @@ 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") yield current_start.strftime("%Y%m%d"), current_end.strftime("%Y%m%d")
current_start = current_end + timedelta(days=1) current_start = current_end + timedelta(days=1)
def fetch_asos_data(stn_id, start_dt, end_dt, service_key, session=None): def fetch_asos_data(stn_id, start_dt, end_dt, service_key):
if session is None:
session = make_requests_session()
url = "http://apis.data.go.kr/1360000/AsosDalyInfoService/getWthrDataList" url = "http://apis.data.go.kr/1360000/AsosDalyInfoService/getWthrDataList"
params = { params = {
@ -48,9 +44,8 @@ def fetch_asos_data(stn_id, start_dt, end_dt, service_key, session=None):
"Accept": "application/json" "Accept": "application/json"
} }
resp = None
try: try:
resp = session.get(url, params=params, headers=headers, timeout=20) resp = requests.get(url, params=params, headers=headers, timeout=15)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
items = data.get("response", {}).get("body", {}).get("items", {}).get("item", []) items = data.get("response", {}).get("body", {}).get("items", {}).get("item", [])
@ -62,14 +57,7 @@ def fetch_asos_data(stn_id, start_dt, end_dt, service_key, session=None):
return items return items
except Exception as e: except Exception as e:
body_preview = None print(f"[ERROR] API 요청 실패: {e}")
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 [] return []
def save_items_to_db(items, conn, table, force_update=False, debug=False): def save_items_to_db(items, conn, table, force_update=False, debug=False):
@ -110,7 +98,7 @@ def save_items_to_db(items, conn, table, force_update=False, debug=False):
data[key] = None data[key] = None
if debug: if debug:
print(f"[DEBUG] {record_date} DB 저장 시도: {data}") print(f"[DEBUG] {record_date} DB 저장 시도: {data}")
continue continue
if force_update: if force_update:
@ -128,7 +116,6 @@ def save_items_to_db(items, conn, table, force_update=False, debug=False):
except Exception as e: except Exception as e:
print(f"[ERROR] 저장 실패: {e}") print(f"[ERROR] 저장 실패: {e}")
traceback.print_exc()
raise raise
def get_latest_date_from_db(conn, table): def get_latest_date_from_db(conn, table):
@ -153,29 +140,22 @@ def main():
if now.hour < 11: if now.hour < 11:
end_date = today - timedelta(days=2) end_date = today - timedelta(days=2)
print(f"[INFO] 오전 11시 이전에는 전전일 데이터가 가장 최근입니다. 최종 검색일자 {end_date}")
else: else:
end_date = today - timedelta(days=1) end_date = today - timedelta(days=1)
print(f"[INFO] 최종 검색일자 {end_date}")
config_start_date = datetime.strptime(config["DATA_API"]["startDt"], "%Y%m%d").date() config_start_date = datetime.strptime(config["DATA_API"]["startDt"], "%Y%m%d").date()
chunk_days = 1000 chunk_days = 1000
session = make_requests_session()
with engine.begin() as conn: with engine.begin() as conn:
print(f"[INFO] DB 저장 최종 일자 점검")
latest_date = get_latest_date_from_db(conn, table) latest_date = get_latest_date_from_db(conn, table)
print(f"[INFO] 최종 저장일 : {latest_date}")
if latest_date is None: if latest_date is None:
start_date = config_start_date start_date = config_start_date
print(f"[INFO] 최종 저장 일자가 존재하지 않아 기본 시작일자를 사용합니다. {start_date}")
else: else:
start_date = max(config_start_date, latest_date + timedelta(days=1)) start_date = max(config_start_date, latest_date + timedelta(days=1))
print(f"[INFO] 시작일자 : {start_date}")
if start_date > end_date: if start_date > end_date:
if debug:
print("[INFO] 최신 데이터가 이미 존재하거나 요청할 데이터가 없습니다.") print("[INFO] 최신 데이터가 이미 존재하거나 요청할 데이터가 없습니다.")
return return
@ -184,8 +164,9 @@ def main():
for stn_id in stn_ids: for stn_id in stn_ids:
for chunk_start, chunk_end in fetch_data_range_chunks(start_dt, end_dt, chunk_days): for chunk_start, chunk_end in fetch_data_range_chunks(start_dt, end_dt, chunk_days):
if debug:
print(f"[INFO] 지점 {stn_id} 데이터 요청 중: {chunk_start} ~ {chunk_end}") print(f"[INFO] 지점 {stn_id} 데이터 요청 중: {chunk_start} ~ {chunk_end}")
items = fetch_asos_data(stn_id, chunk_start, chunk_end, service_key, session=session) items = fetch_asos_data(stn_id, chunk_start, chunk_end, service_key)
if items: if items:
save_items_to_db(items, conn, table, force_update, debug) save_items_to_db(items, conn, table, force_update, debug)
else: else:

View File

@ -1,19 +1,15 @@
flask==3.0.0 flask
sqlalchemy==2.0.23 sqlalchemy
pymysql==1.1.0 pymysql
pyyaml==6.0.1 pyyaml
requests==2.31.0 requests
pandas==2.1.3 pandas
openpyxl==3.1.2 openpyxl
xlrd==2.0.1 xlrd>=2.0.1
google-analytics-data==0.18.5 google-analytics-data
prophet==1.1.5 prophet
statsmodels==0.14.0 statsmodels
scikit-learn==1.3.2 scikit-learn
matplotlib==3.8.2 customtkinter
customtkinter==5.2.0 tkcalendar
tkcalendar==1.6.1 tabulate
tabulate==0.9.0
watchdog==3.0.0
python-dotenv==1.0.0
pytz==2023.3

View File

@ -1,15 +0,0 @@
{
"folders": [
{
"name": "static",
"path": "."
}
],
"settings": {
"python.defaultInterpreterPath": "${workspaceFolder}\\.venv\\Scripts\\python.exe",
"python.terminal.activateEnvironment": true,
"python.analysis.extraPaths": [
"${workspaceFolder}\\lib"
]
}
}