diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bca2907 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,129 @@ +# SolarPower Agents Guide + +This document provides context, commands, and guidelines for AI agents working on the SolarPower codebase. + +## 1. Project Overview +SolarPower is an integrated solar power monitoring system consisting of: +- **Crawler**: Python-based data collector running on Oracle Cloud (formerly Synology NAS). Uses Tailscale to route via NAS IP to avoid blocks. +- **API Server**: FastAPI backend on Oracle Cloud acting as a middleware. +- **App**: React Native (Expo) mobile/web application for monitoring. +- **Database**: Supabase (PostgreSQL) for data persistence. + +## 2. Directory Structure +- `crawler/`: Python crawlers and scheduler. + - `crawlers/`: Individual site crawler implementations. + - `crawler_manager.py`: Smart scheduler using SQLite (`crawler_manager.db`). +- `api_server/`: FastAPI backend. + - `app/routers/`: API endpoints (`plants`, `stats`, `upload`). + - `app/core/`: Configuration and utilities. +- `app/`: React Native Expo frontend. + - `components/`: Reusable UI components. + - `screens/`: Application screens. + +## 3. Development Commands + +### ๐Ÿ“ฑ Mobile App (`app/`) +- **Environment**: Node.js, Expo +- **Install**: `npm install` +- **Run (Dev)**: `npx expo start` + - Press `w` for web, `a` for Android. +- **Build (Web)**: `npx expo export -p web` + - Outputs to `dist/` directory. + - Use this command for deployment builds. +- **Lint**: No strict ESLint config. Follow existing code patterns (functional components, hooks). +- **Test**: + - **E2E**: Use `playwright` skill/tool to verify the web build running on a local server. + - **Manual**: Run `npx expo start` and use Expo Go. + +### โšก API Server (`api_server/`) +- **Environment**: Python 3.10+ +- **Install**: `pip install -r requirements.txt` +- **Run (Dev)**: `uvicorn app.main:app --reload` + - Runs on port 8000 by default. +- **Run (Prod)**: Managed via systemd: `sudo systemctl restart solar-api` +- **Docs**: Access Swagger UI at `http://localhost:8000/docs` +- **Test**: + - No automated test suite (pytest) currently. + - Verify endpoints manually using `curl` or Swagger UI. + +### ๐Ÿ Crawler (`crawler/`) +- **Environment**: Python 3.10+ +- **Install**: Manual dependency installation (requests, python-dotenv, supabase). +- **Run (Manual)**: `python main.py` + - Runs in `LEARNING` or `OPTIMIZED` mode based on `crawler_manager.db`. +- **Run (Force)**: `python main.py --force` (Ignores scheduler constraints) +- **GUI Tool**: `python crawler_gui.py` (For historical data recovery and log cleaning) +- **Testing**: + - No standard test runner. Use standalone scripts in `tests/`. + - **Run Single Test**: `python tests/debug_kremc.py` (or similar script for specific site). + +## 4. Code Style & Standards + +### ๐Ÿ Python (Crawler & API) +- **Style**: Follow PEP 8 standards. +- **Indentation**: 4 spaces. +- **Typing**: Use type hints where possible (`def func(a: int) -> str:`). +- **Frameworks**: + - **FastAPI**: Use Pydantic models for request/response schemas. + - **Crawler**: Use `crawler_manager` for scheduling. Do not hardcode run times. +- **Imports**: Group standard lib, third-party, then local modules. +- **Error Handling**: + - API: Raise `HTTPException` with clear status codes. + - Crawler: Catch exceptions per site to prevent one failure from stopping others. +- **Async**: API Server uses `async/await`. Crawler is synchronous (mostly). +- **Naming**: `snake_case` for functions/variables, `PascalCase` for classes. + +### โš›๏ธ JavaScript/React Native (`app/`) +- **Style**: Functional Components with Hooks (`useState`, `useEffect`). +- **Indentation**: 2 spaces. +- **Quotes**: Single quotes `'` preferred for strings. +- **Semicolons**: Always use semicolons. +- **Naming**: `camelCase` for variables/functions, `PascalCase` for components. +- **Styling**: Use `StyleSheet.create({})`. Avoid inline style objects if possible. +- **Navigation**: Uses `@react-navigation`. Ensure strict typing for route params. +- **Libraries**: + - Charts: `react-native-gifted-charts` + - UI: `react-native-svg`, `expo-linear-gradient` +- **State**: Local state preferred. Keep components pure where possible. + +## 5. Deployment Workflows + +### ๐Ÿš€ Web App Deployment +1. **Build**: Run `npx expo export -p web` locally to generate `dist/`. +2. **Upload**: Use SCP to transfer `dist/` to the server. + - Example: `scp -r dist user@server:~/dist_temp` +3. **Deploy**: Move the uploaded folder to the web root (`/var/www/html/dist`). + - Command: `ssh user@server "sudo rm -rf /var/www/html/dist && sudo mv ~/dist_temp /var/www/html/dist"` +4. **Refresh**: `sudo systemctl reload nginx` (optional, for cache clearing). + +### โ˜๏ธ Server Deployment +1. **Push**: Commit and push changes to the remote Git repository. +2. **Pull**: SSH into the server and pull changes. + - Command: `ssh user@server "cd ~/solorpower_server && git pull"` +3. **Restart**: Restart the API service. + - Command: `sudo systemctl restart solar-api` + +## 6. Architecture & Patterns + +### ๐Ÿง  Crawler Scheduler (`crawler_manager.py`) +- **LEARNING Mode**: Runs every cycle to determine update patterns. +- **OPTIMIZED Mode**: Runs only during specific 10-minute windows around the learned update time. +- **Storage**: Uses local SQLite (`site_rules` table) to persist scheduling rules. + +### ๐ŸŒ API Structure +- **Routers**: + - `plants`: Basic plant info and real-time status. + - `stats`: Aggregated statistics (daily, monthly, yearly). + - `upload`: Excel file processing for manual data entry. +- **CORS**: Configured to allow all origins (`*`) in development. + +## 7. Critical Rules for Agents +1. **Timezones**: All internal logic should handle KST (UTC+9) correctly. Be careful with UTC conversions in DB interactions. +2. **Data Integrity**: `crawler_manager` prevents duplicate runs. Respect `should_run` checks. +3. **Error Recovery**: Crawlers must be robust against network failures (retry logic). +4. **Secrets**: Never commit `.env` files. Use `python-dotenv`. +5. **Testing**: Always verify changes locally before deploying. + - **App**: Check UI changes on local web server. + - **API**: Check endpoints response. + - **Crawler**: Run debug scripts. +6. **Networking**: When modifying crawlers, be aware that Oracle Cloud uses Tailscale to route traffic through the Synology NAS (Exit Node) to avoid IP blocks from monitoring sites. Ensure Tailscale is active on the host. diff --git a/README.md b/README.md new file mode 100644 index 0000000..781b9c7 --- /dev/null +++ b/README.md @@ -0,0 +1,335 @@ +# โ˜€๏ธ SolarPower ํƒœ์–‘๊ด‘ ๋ฐœ์ „ ํ†ตํ•ฉ ๊ด€์ œ ์‹œ์Šคํ…œ + +ํƒœ์–‘๊ด‘ ๋ฐœ์ „์†Œ์˜ ์‹ค์‹œ๊ฐ„ ๋ฐœ์ „ ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์ง‘ํ•˜๊ณ  ๋ชจ๋‹ˆํ„ฐ๋งํ•˜๋Š” ํ†ตํ•ฉ ๊ด€์ œ ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค. + +--- + +## ๐Ÿ“‹ ๋ชฉ์ฐจ + +1. [์‹œ์Šคํ…œ ๊ฐœ์š”](#์‹œ์Šคํ…œ-๊ฐœ์š”) +2. [์ „์ฒด ์•„ํ‚คํ…์ฒ˜](#์ „์ฒด-์•„ํ‚คํ…์ฒ˜) +3. [์ธํ”„๋ผ ๊ตฌ์„ฑ](#์ธํ”„๋ผ-๊ตฌ์„ฑ) +4. [ํฌ๋กค๋Ÿฌ (Synology NAS / PC)](#ํฌ๋กค๋Ÿฌ-synology-nas--pc) +5. [API ์„œ๋ฒ„ (Oracle Cloud)](#api-์„œ๋ฒ„-oracle-cloud) +6. [๋ชจ๋ฐ”์ผ ์•ฑ (React Native/Expo)](#๋ชจ๋ฐ”์ผ-์•ฑ-react-nativeexpo) +7. [๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค (Supabase)](#๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค-supabase) +8. [๋ฌธ์ œ ํ•ด๊ฒฐ ๊ฐ€์ด๋“œ](#๋ฌธ์ œ-ํ•ด๊ฒฐ-๊ฐ€์ด๋“œ) + +--- + +## ์‹œ์Šคํ…œ ๊ฐœ์š” + +### ์ง€์› ๋ฐœ์ „์†Œ + +| ํ˜ธ๊ธฐ | ํฌ๋กค๋Ÿฌ ํƒ€์ž… | ๋ชจ๋‹ˆํ„ฐ๋ง ์‹œ์Šคํ…œ | ๋น„๊ณ  | +|------|------------|----------------|------| +| 1ํ˜ธ๊ธฐ | NREMS | nrems.co.kr | 1,2ํ˜ธ๊ธฐ ํ†ตํ•ฉ ๊ณ„์ • | +| 2ํ˜ธ๊ธฐ | NREMS | nrems.co.kr | ์ธ๋ฒ„ํ„ฐ๋ณ„ ๋ถ„๋ฆฌ ์ฒ˜๋ฆฌ | +| 3ํ˜ธ๊ธฐ | NREMS | nrems.co.kr | | +| 4ํ˜ธ๊ธฐ | NREMS | nrems.co.kr | | +| 5ํ˜ธ๊ธฐ | KREMC | kremc.kr | JWT ์ธ์ฆ, ์›” ๋‹จ์œ„ ์ˆ˜์ง‘ | +| 6ํ˜ธ๊ธฐ | Sun-WMS | sun-wms.com | ์•”ํ˜ธํ™”๋œ ํŽ˜์ด๋กœ๋“œ, ์›” ๋‹จ์œ„ ์ˆ˜์ง‘ | +| 8ํ˜ธ๊ธฐ | Hyundai | hyundai-es.co.kr | ์›”๋ณ„ ํ†ต๊ณ„ API ์‚ฌ์šฉ (์ดˆ๊ณ ์†) | +| 9ํ˜ธ๊ธฐ | NREMS | nrems.co.kr | | +| 10ํ˜ธ๊ธฐ | CMSolar | cmsolar2.kr | | + +--- + +## ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ + +```mermaid +flowchart TD + subgraph ์™ธ๋ถ€์‹œ์Šคํ…œ["์™ธ๋ถ€ ๋ชจ๋‹ˆํ„ฐ๋ง ์‹œ์Šคํ…œ"] + NREMS["NREMS
(1,2,3,4,9ํ˜ธ๊ธฐ)"] + KREMC["KREMC
(5ํ˜ธ๊ธฐ)"] + SUNWMS["Sun-WMS
(6ํ˜ธ๊ธฐ)"] + HYUNDAI["Hyundai
(8ํ˜ธ๊ธฐ)"] + CMSOLAR["CMSolar
(10ํ˜ธ๊ธฐ)"] + end + + subgraph CLOUD["Oracle Cloud (CRAWLER & API)"] + FASTAPI["FastAPI Server
(Port 8000)"] + NGINX["Nginx Reverse Proxy
(Port 443)"] + CRAWLER["Python Crawler
(Tailscale Routing)"] + end + + subgraph NAS["Synology NAS (Exit Node)"] + TAILSCALE["Tailscale Node
(Home IP Mapping)"] + end + + subgraph SUPABASE["Supabase (PostgreSQL)"] + DB_PLANTS["plants ํ…Œ์ด๋ธ”"] + DB_LOGS["solar_logs ํ…Œ์ด๋ธ”"] + DB_DAILY["daily_stats ํ…Œ์ด๋ธ”"] + end + + subgraph CLIENT["ํด๋ผ์ด์–ธํŠธ"] + APP["React Native App"] + WEB["Web Browser"] + end + + NREMS --> CRAWLER + KREMC --> CRAWLER + SUNWMS --> CRAWLER + HYUNDAI --> CRAWLER + CMSOLAR --> CRAWLER + + CRAWLER -.->|Tailscale Tunnel| TAILSCALE + TAILSCALE -.->|Proxy Request| ์™ธ๋ถ€์‹œ์Šคํ…œ + + CRAWLER -->|INSERT| DB_LOGS + CRAWLER -->|UPSERT| DB_DAILY + + FASTAPI -->|SELECT| DB_PLANTS + FASTAPI -->|SELECT| DB_LOGS + FASTAPI -->|SELECT| DB_DAILY + + NGINX --> FASTAPI + + APP -->|HTTPS| NGINX + WEB -->|HTTPS| NGINX +``` + +--- + +## ์ธํ”„๋ผ ๊ตฌ์„ฑ + +### ์„œ๋ฒ„ ์ ‘์† ์ •๋ณด + +| ์„œ๋ฒ„ | ์šฉ๋„ | ์ ‘์† ๋ช…๋ น์–ด | +|------|------|------------| +| **Oracle Cloud** | API ์„œ๋ฒ„ & ํฌ๋กค๋Ÿฌ | `ssh -i "~/.ssh/holdem_server.key" ubuntu@140.245.73.212` | +| **Synology NAS** | ๋ฐ์ดํ„ฐ ๋ฐฑ์—… & Tailscale Exit Node | `ssh -p2022 haneulai@192.168.45.151` | + +### ๋„๋ฉ”์ธ + +| ๋„๋ฉ”์ธ | ์šฉ๋„ | +|--------|------| +| `https://solorpower.dadot.net` | API ์„œ๋ฒ„ (Nginx ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ) | + +--- + +## ํฌ๋กค๋Ÿฌ (Oracle Cloud / PC) + +### ์œ„์น˜ +``` +d:\dev\etc\SolorPower\crawler\ (Local PC) +/home/ubuntu/SolorPower/crawler/ (Oracle Cloud) +``` + +### ๋„คํŠธ์›Œํฌ ๊ตฌ์„ฑ (Tailscale) +์ผ๋ถ€ ๋ชจ๋‹ˆํ„ฐ๋ง ์‚ฌ์ดํŠธ์—์„œ Oracle Cloud IP๊ฐ€ ์ฐจ๋‹จ๋˜๋Š” ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด **Tailscale**์„ ์‚ฌ์šฉํ•˜์—ฌ ๋„คํŠธ์›Œํฌ๋ฅผ ๊ตฌ์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค. +- **์ฃผ ํ˜ธ์ŠคํŠธ**: Oracle Cloud (ํฌ๋กค๋Ÿฌ ์‹คํ–‰) +- **Exit Node**: Synology NAS (๊ฐ€์ •์šฉ IP ์ œ๊ณต) +- **๋™์ž‘**: ํฌ๋กค๋ง ์š”์ฒญ ์‹œ Tailscale ํ„ฐ๋„์„ ํ†ตํ•ด NAS์˜ IP๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์™ธ๋ถ€ ์‹œ์Šคํ…œ์— ์ ‘๊ทผํ•ฉ๋‹ˆ๋‹ค. + +### ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ +``` +crawler/ +โ”œโ”€โ”€ main.py # ๋ฉ”์ธ ์‹คํ–‰ ํŒŒ์ผ (์‹ค์‹œ๊ฐ„ ์ˆ˜์ง‘์šฉ) +โ”œโ”€โ”€ crawler_gui.py # GUI ๊ด€๋ฆฌ ๋„๊ตฌ (๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘์šฉ) โ† NEW! +โ”œโ”€โ”€ config.py # ๋ฐœ์ „์†Œ/์ธ์ฆ ์„ค์ • +โ”œโ”€โ”€ database.py # Supabase ์—ฐ๋™ +โ”œโ”€โ”€ fetch_history.py # ๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ๋กœ์ง +โ”œโ”€โ”€ .env # ํ™˜๊ฒฝ ๋ณ€์ˆ˜ (SUPABASE_URL, SUPABASE_KEY) +โ””โ”€โ”€ crawlers/ + โ”œโ”€โ”€ __init__.py # ํฌ๋กค๋Ÿฌ ๋ผ์šฐํŒ… + โ”œโ”€โ”€ base.py # ๊ณตํ†ต ์œ ํ‹ธ๋ฆฌํ‹ฐ + โ”œโ”€โ”€ nrems.py # NREMS ํฌ๋กค๋Ÿฌ + โ”œโ”€โ”€ kremc.py # KREMC ํฌ๋กค๋Ÿฌ + โ”œโ”€โ”€ sun_wms.py # Sun-WMS ํฌ๋กค๋Ÿฌ + โ”œโ”€โ”€ hyundai.py # Hyundai ํฌ๋กค๋Ÿฌ + โ””โ”€โ”€ cmsolar.py # CMSolar ํฌ๋กค๋Ÿฌ +``` + +### GUI ๊ด€๋ฆฌ ๋„๊ตฌ (NEW) +๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ๋ฅผ ํŽธ๋ฆฌํ•˜๊ฒŒ ์ˆ˜์ง‘ํ•˜๊ณ  ๋ณต๊ตฌํ•˜๊ธฐ ์œ„ํ•œ GUI ๋„๊ตฌ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +1. **์‹คํ–‰**: + ```bash + python crawler_gui.py + ``` +2. **๊ธฐ๋Šฅ**: + - ๋ฐœ์ „์†Œ๋ณ„ ์‹ค์‹œ๊ฐ„ ์ƒํƒœ ํ™•์ธ + - **๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘**: ๋‚ ์งœ ๋ฒ”์œ„๋ฅผ ์ง€์ •ํ•˜์—ฌ ์ผ๋ณ„/์›”๋ณ„ ๋ฐ์ดํ„ฐ ์ผ๊ด„ ์ˆ˜์ง‘ + - **๋กœ๊ทธ ์ •๋ฆฌ**: ๋ฏธ๋ž˜ ๋‚ ์งœ๋กœ ์ž˜๋ชป ๋“ค์–ด๊ฐ„ ์˜ค์—ผ ๋ฐ์ดํ„ฐ ์‚ญ์ œ ๊ธฐ๋Šฅ + +### ์ž๋™ ์ˆ˜์ง‘ (Cron) +```bash +# 30๋ถ„๋งˆ๋‹ค ์‹คํ–‰ (Oracle Cloud ํ™˜๊ฒฝ) +*/30 * * * * cd /home/ubuntu/SolorPower/crawler && /home/ubuntu/SolorPower/crawler/venv/bin/python main.py >> cron.log 2>&1 +``` + +### ํฌ๋กค๋Ÿฌ๋ณ„ ๊ฐœ์„  ์‚ฌํ•ญ (2026.01) +- **๊ณตํ†ต**: ๋ฏธ๋ž˜ ๋‚ ์งœ ๋ฐ์ดํ„ฐ(UTC/KST ํ˜ผ๋™)๊ฐ€ ์ €์žฅ๋˜๋Š” ๋ฒ„๊ทธ ์ˆ˜์ • ๋ฐ ์ž๋™ ์ฒญ์†Œ ๋กœ์ง ์ถ”๊ฐ€. +- **NREMS**: ์ผ๋ณ„ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์‹œ ์›” ๋‹จ์œ„๋กœ ๋ถ„ํ•  ์š”์ฒญํ•˜์—ฌ ๋Š๊น€ ๋ฐฉ์ง€. +- **KREMC**: 5ํ˜ธ๊ธฐ ์ธ์ฆ ํ† ํฐ ํŒŒ์‹ฑ ๊ฐœ์„  ๋ฐ ์›” ๋‹จ์œ„ ๋ฐฐ์น˜ ์ˆ˜์ง‘ ์ ์šฉ. +- **Sun-WMS**: 6ํ˜ธ๊ธฐ ์„ธ์…˜ ์œ ์ง€ ๋ฐ ์›” ๋‹จ์œ„ ์ˆ˜์ง‘ ์•ˆ์ •ํ™”. +- **Hyundai**: 8ํ˜ธ๊ธฐ `getSolraMonthWork` API๋ฅผ ํ™œ์šฉํ•˜์—ฌ 1๋…„์น˜ ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์‹ญ ์ดˆ ๋‚ด์— ์ˆ˜์ง‘ํ•˜๋„๋ก ์ตœ์ ํ™”. + +--- + +## API ์„œ๋ฒ„ (Oracle Cloud) + +### ์œ„์น˜ +``` +/home/ubuntu/SolorPower/api_server/ +``` + +### ํ™˜๊ฒฝ ์„ค์ • (.env) +```env +SUPABASE_URL=https://xxxx.supabase.co +SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6... +DEBUG=false +``` + +### ์„œ๋น„์Šค ๊ด€๋ฆฌ +`sudo systemctl restart solar-api` + +### API ์—”๋“œํฌ์ธํŠธ + +| Method | Endpoint | ์„ค๋ช… | +|--------|----------|------| +| GET | `/plants/{plant_id}/stats` | ๋ฐœ์ „์†Œ ํ†ต๊ณ„ ์กฐํšŒ (์ผ/์›”/๋…„) | +| GET | `/plants/{plant_id}/stats/today` | ์˜ค๋Š˜(๋˜๋Š” ํŠน์ •์ผ) ์‹œ๊ฐ„๋ณ„ ์ƒ์„ธ ์กฐํšŒ | +| POST | `/plants/{plant_id}/upload` | ์—‘์…€ ๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ (์ผ๊ฐ„) | +| POST | `/plants/{plant_id}/upload/monthly` | ์—‘์…€ ๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ (์›”๊ฐ„) | + +**ํŒŒ๋ผ๋ฏธํ„ฐ ์„ค๋ช…**: +- `period`: `day` (์ผ๋ณ„), `month` (์›”๋ณ„), `year` (์—ฐ๋„๋ณ„) +- `year`: (์˜ต์…˜) ์กฐํšŒ ๊ธฐ์ค€ ์—ฐ๋„ +- `month`: (์˜ต์…˜) `period=day`์‹œ ์กฐํšŒํ•  ์›” +- `date`: (์˜ต์…˜) `stats/today`์‹œ ์กฐํšŒํ•  ๋‚ ์งœ (YYYY-MM-DD) + +--- + +## ๋ชจ๋ฐ”์ผ ์•ฑ & ์›น (React Native/Expo) + +### ์œ„์น˜ +``` +d:\dev\etc\SolorPower\app\ +``` + +### ์ฃผ์š” ๊ธฐ๋Šฅ +- **์‹ค์‹œ๊ฐ„/ํ†ต๊ณ„ ํ†ตํ•ฉ**: ๋Œ€์‹œ๋ณด๋“œ ๋ฐ ์ƒ์„ธ ์ฐจํŠธ(์ผ/์›”/๋…„). +- **๋‚ ์งœ ๋„ค๋น„๊ฒŒ์ด์…˜**: `โ—€ 2026๋…„ 1์›” โ–ถ` ๋“ฑ์˜ ์ปจํŠธ๋กค๋กœ ์ž์œ ๋กœ์šด ๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ํƒ์ƒ‰. +- **์—‘์…€ ์—…๋กœ๋“œ**: + - **์ผ๊ฐ„**: `date`, `generation` ๋˜๋Š” `year, month, day, kwh` ํฌ๋งท ์ง€์›. + - **์›”๊ฐ„**: `year, month, kwh` ํฌ๋งท ์ง€์›. + - ๋ณ„์นญ(`๋ฐœ์ „๋Ÿ‰`, `kwh` ๋“ฑ) ๋ฐ ์…€ ๋ณ‘ํ•ฉ ์ฒ˜๋ฆฌ ์ง€์›. +- **๋ฐ˜์‘ํ˜• ์›น ์ง€์›**: PC/๋ชจ๋ฐ”์ผ ๋ธŒ๋ผ์šฐ์ € ์ตœ์ ํ™”. + +--- + +## ์›น ์•ฑ ๋ฐฐํฌ ๊ฐ€์ด๋“œ (Web Deployment) + +React Native(Expo) ์•ฑ์„ ์›น์œผ๋กœ ๋นŒ๋“œํ•˜๊ณ  Nginx ์„œ๋ฒ„์— ๋ฐฐํฌํ•˜๋Š” ๊ณผ์ •์ž…๋‹ˆ๋‹ค. + +### 1. ์›น ๋นŒ๋“œ (Local PC) +```bash +cd d:\dev\etc\SolorPower\app +npm run export:web +``` +- `dist` ํด๋”์— ์ •์  ์›น ํŒŒ์ผ๋“ค์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. + +### 2. ์„œ๋ฒ„ ์—…๋กœ๋“œ (SCP) +๋นŒ๋“œ๋œ `dist` ํด๋”๋ฅผ Oracle Cloud ์„œ๋ฒ„์˜ ์›น ๋ฃจํŠธ๋กœ ์ „์†กํ•ฉ๋‹ˆ๋‹ค. +*(์ฐธ๊ณ : `/var/www/html/` ๊ถŒํ•œ ๋ฌธ์ œ๋กœ ์ธํ•ด ์ž„์‹œ ํด๋”๋กœ ์ „์†ก ํ›„ `sudo`๋กœ ์ด๋™ํ•ด์•ผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค)* + +```bash +# 1. ํ™ˆ ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์ „์†ก +scp -i "~/.ssh/holdem_server.key" -r dist ubuntu@140.245.73.212:~/dist_temp + +# 2. ์„œ๋ฒ„ ์ ‘์† ํ›„ ์ด๋™ +ssh -i "~/.ssh/holdem_server.key" ubuntu@140.245.73.212 +sudo rm -rf /var/www/html/dist +sudo mv ~/dist_temp /var/www/html/dist +``` + +### 3. Nginx ์„ค์ • (์„œ๋ฒ„ ๋ถ„์„ ๊ฒฐ๊ณผ) +์‹ค์ œ ์šด์˜ ์ค‘์ธ Nginx ์„ค์ • (`/etc/nginx/sites-enabled/solorpower`)์˜ ํ•ต์‹ฌ ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค. + +**๋ผ์šฐํŒ… ๊ทœ์น™**: +- **Frontend**: `/var/www/html/dist` (React Static Files) +- **Backend**: `http://127.0.0.1:8000` (FastAPI) + - `/plants` (๋ฐ์ดํ„ฐ/ํ†ต๊ณ„ API) + - `/docs`, `/openapi.json` (API ๋ฌธ์„œ) + +```nginx +server { + listen 80; + server_name solorpower.dadot.net; + # HTTPS ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ์ƒ๋žต... + + listen 443 ssl; + # SSL ์ธ์ฆ์„œ ์„ค์ • ์ƒ๋žต... + + # 1. React Frontend (์ •์  ํŒŒ์ผ) + location / { + root /var/www/html/dist; + index index.html; + try_files $uri $uri/ /index.html; + } + + # 2. FastAPI Backend (API ํ”„๋ก์‹œ) + # plants(๋ฐ์ดํ„ฐ), docs(๋ฌธ์„œ) ๊ฒฝ๋กœ๋งŒ ๋ฐฑ์—”๋“œ๋กœ ํ† ์Šค + location ~ ^/(plants|docs|openapi.json) { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### 4. ์ ์šฉ +```bash +sudo systemctl reload nginx +``` +์ด์ œ `https://solorpower.dadot.net` ์—์„œ ๋ฐฐํฌ๋œ ์›น ์•ฑ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +--- + +## ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค (Supabase) + +### ์ฃผ์š” ํ…Œ์ด๋ธ” +1. **plants**: ๋ฐœ์ „์†Œ ๊ธฐ๋ณธ ์ •๋ณด. +2. **solar_logs**: ์‹ค์‹œ๊ฐ„ ๋ฐœ์ „ ๋กœ๊ทธ (10๋ถ„/30๋ถ„ ๋‹จ์œ„). `created_at` ๊ธฐ์ค€. +3. **daily_stats**: ์ผ๋ณ„ ๋ฐœ์ „๋Ÿ‰ ํ†ต๊ณ„. `date` ๊ธฐ์ค€. (๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ๋Š” ์—ฌ๊ธฐ์— ์ €์žฅ๋จ) +4. **monthly_stats**: ์›”๋ณ„ ๋ฐœ์ „๋Ÿ‰ ํ†ต๊ณ„. `month` ๊ธฐ์ค€. + +--- + +## ๊ฐœ๋ฐœ ํžˆ์Šคํ† ๋ฆฌ + +### 2026-04-22 +- **ํฌ๋กค๋Ÿฌ ์ธํ”„๋ผ ์ด์ „**: ํฌ๋กค๋Ÿฌ ์‹คํ–‰ ํ™˜๊ฒฝ์„ Synology NAS์—์„œ Oracle Cloud๋กœ ์ด์ „. +- **๋„คํŠธ์›Œํฌ ์šฐํšŒ ์„ค์ •**: Oracle Cloud IP ์ฐจ๋‹จ ์ด์Šˆ ๋Œ€์‘์„ ์œ„ํ•ด Tailscale์„ ๋„์ž…, Synology NAS๋ฅผ Exit Node๋กœ ์„ค์ •ํ•˜์—ฌ ๊ฐ€์ •์šฉ IP๋กœ ํฌ๋กค๋ง ์ˆ˜ํ–‰. +- **์•ˆ์ •์„ฑ ๊ฐ•ํ™”**: ํด๋ผ์šฐ๋“œ ํ™˜๊ฒฝ ์ด์ „์„ ํ†ตํ•ด NAS ๋ฆฌ์†Œ์Šค ๋ถ€ํ•˜ ๊ฐ์†Œ ๋ฐ ์ˆ˜์ง‘ ์•ˆ์ „์„ฑ ํ™•๋ณด. + +### 2026-01-30 +- **๋ฐ์ดํ„ฐ ๋ณต๊ตฌ ๋ฐ ์•ˆ์ •ํ™”**: 1์›” 28-29์ผ ๋ฐ์ดํ„ฐ ๋ˆ„๋ฝ ๋ณต๊ตฌ ์™„๋ฃŒ. +- **์ž๋™ ํ†ต๊ณ„ ์ง‘๊ณ„ ๊ฐœ์„ **: `daily_summary.py`๊ฐ€ ๋‚ ์งœ ํŒŒ๋ผ๋ฏธํ„ฐ ์—†์ด ์‹คํ–‰๋  ๊ฒฝ์šฐ ์ž๋™์œผ๋กœ '์–ด์ œ' ๋‚ ์งœ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์ง‘๊ณ„ํ•˜๋„๋ก ์ˆ˜์ •. (์ƒˆ๋ฒฝ ์‹คํ–‰ ์•ˆ์ „์„ฑ ํ™•๋ณด) +- **์›”๊ฐ„ ํ†ต๊ณ„ ์ž๋™ํ™”**: ์ผ์ผ ์ง‘๊ณ„ ์‹œ ํ•ด๋‹น ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์ธ ๊ฒฝ์šฐ, ์ž๋™์œผ๋กœ `monthly_stats`๊นŒ์ง€ ์ง‘๊ณ„/๊ฐฑ์‹ ํ•˜๋Š” ๋กœ์ง ์ถ”๊ฐ€. +- **Git ์ €์žฅ์†Œ ๋ถ„๋ฆฌ**: + - `crawler`: https://gitea.dadot.synology.me/haneulai/solorpower_crawler.git + - `app`: https://gitea.dadot.synology.me/haneulai/solorpower_app.git + +### 2026-01-28 +- **Stats API ๊ณ ๋„ํ™”**: ๋‚ ์งœ/์›” ํƒ์ƒ‰์„ ์œ„ํ•œ ํŒŒ๋ผ๋ฏธํ„ฐ(`month`, `date`) ์ถ”๊ฐ€. +- **์—ฐ๊ฐ„ ํ†ต๊ณ„ ๊ฐœ์„ **: ๋‹จ์ผ ์—ฐ๋„ ์กฐํšŒ์—์„œ 'ํ•ด๋‹น ์—ฐ๋„ ํฌํ•จ ์ตœ๊ทผ 5๋…„' ์กฐํšŒ๋กœ ๋ณ€๊ฒฝ. +- **์—‘์…€ ์—…๋กœ๋“œ ์œ ์—ฐ์„ฑ**: `year, month, day` ์ปฌ๋Ÿผ์ด ๋ถ„๋ฆฌ๋œ ํŒŒ์ผ๋„ ์ž๋™ ์ธ์‹ ๋ฐ ์ฒ˜๋ฆฌ. +- **Frontend ๊ฐœ์„ **: ์ฐจํŠธ ์ƒ๋‹จ ๋‚ ์งœ ๋„ค๋น„๊ฒŒ์ด์…˜(`โ—€ โ–ถ`) ์ถ”๊ฐ€, ์กฐํšŒ ๊ธฐ๊ฐ„ ๋ฒ”์œ„ ํ‘œ์‹œ ๊ฐ•ํ™”. + +### 2026-01-27 +- **Crawler ๊ตฌ์กฐํ™”**: `crawler_manager.py` ๋„์ž… (์Šค๋งˆํŠธ ์Šค์ผ€์ค„๋ง, ํ•™์Šต/์ตœ์ ํ™” ๋ชจ๋“œ). +- **GUI & Documentation**: ๊ด€๋ฆฌ์ž GUI ๊ธฐ๋Šฅ ํ™•์žฅ ๋ฐ `crawler_structure.md` ๋ฌธ์„œํ™”. +- **๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์ตœ์ ํ™”**: ๋ชจ๋“  ํฌ๋กค๋Ÿฌ ์›” ๋‹จ์œ„ ๋ถ„ํ•  ์ˆ˜์ง‘ ์ง€์›. + +### 2026-01-23 +- ํ†ต๊ณ„ ์กฐํšŒ API ์ถ”๊ฐ€ ๋ฐ ์—‘์…€ ์—…๋กœ๋“œ API ๊ตฌํ˜„. + +### 2026-01-22 +- ๋Œ€์‹œ๋ณด๋“œ ํ•„๋“œ๋ช… ๋งคํ•‘ ์ˆ˜์ •. + diff --git a/app/server.log b/app/server.log new file mode 100644 index 0000000..c4a5fe1 --- /dev/null +++ b/app/server.log @@ -0,0 +1,33 @@ +Starting up http-server, serving dist + +http-server version: 14.1.1 + +http-server settings: +CORS: disabled +Cache: 3600 seconds +Connection Timeout: 120 seconds +Directory Listings: visible +AutoIndex: visible +Serve GZIP Files: false +Serve Brotli Files: false +Default File Extension: none + +Available on: + http://172.31.160.1:3004 + http://192.168.45.31:3004 + http://127.0.0.1:3004 +Hit CTRL-C to stop the server + +[2026-02-12T01:44:51.238Z] "HEAD /" "curl/8.12.1" +(node:1368) [DEP0066] DeprecationWarning: OutgoingMessage.prototype._headers is deprecated +(Use `node --trace-deprecation ...` to show where the warning was created) +[2026-02-12T01:46:00.070Z] "GET /" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0" +[2026-02-12T01:46:00.119Z] "GET /_expo/static/js/web/AppEntry-943aad0f5c4e74ff22d7c538b45fdd54.js" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0" +[2026-02-12T01:46:00.399Z] "GET /favicon.ico" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0" +[2026-02-12T01:46:00.400Z] "GET /favicon.ico" Error (404): "Not found" +[2026-02-12T01:51:33.504Z] "GET /" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0" +[2026-02-12T01:52:17.173Z] "HEAD /" "curl/8.12.1" +[2026-02-12T01:52:33.791Z] "GET /" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0" +[2026-02-12T01:52:33.821Z] "GET /_expo/static/js/web/AppEntry-8dbed585399d63adcee0c00c59fe829b.js" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0" +[2026-02-12T01:52:34.014Z] "GET /favicon.ico" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0" +[2026-02-12T01:52:34.015Z] "GET /favicon.ico" Error (404): "Not found" diff --git a/crawler/tools/_check_alert.py b/crawler/tools/_check_alert.py new file mode 100644 index 0000000..d92f91a --- /dev/null +++ b/crawler/tools/_check_alert.py @@ -0,0 +1,64 @@ +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + os.environ['SUPABASE_URL'] = 'https://qgelkvfjnhqwuvltmqct.supabase.co' + os.environ['SUPABASE_KEY'] = 'sb_secret_BFkr9u1Npu3asKDWTn40tg_Qnn1uKLc' + +from database import get_supabase_client +import sqlite3 +from pathlib import Path + +client = get_supabase_client() +if not client: + print("Supabase ์—ฐ๊ฒฐ ์‹คํŒจ") + sys.exit(1) + +# 1. ์˜ค๋Š˜ KST 15:00~18:00 = UTC 06:00~09:00 ์˜ nrems-03 solar_logs ์กฐํšŒ +print("=" * 60) +print("[1] nrems-03 ์˜ค๋Š˜ 15:00~18:00 KST (solar_logs)") +print("=" * 60) +resp = client.table('solar_logs') \ + .select('plant_id, current_kw, today_kwh, status, created_at') \ + .eq('plant_id', 'nrems-03') \ + .gte('created_at', '2026-05-14T06:00:00+00:00') \ + .lte('created_at', '2026-05-14T09:30:00+00:00') \ + .order('created_at') \ + .execute() + +for row in resp.data: + print(f" {row['created_at']} kw={row['current_kw']} today={row['today_kwh']} status={row['status']}") +print(f"์ด {len(resp.data)}๊ฑด\n") + +# 2. daily_stats ์˜ค๋Š˜ ๋‚ ์งœ ํ™•์ธ +print("=" * 60) +print("[2] daily_stats ์˜ค๋Š˜ nrems-03") +print("=" * 60) +resp2 = client.table('daily_stats') \ + .select('*') \ + .eq('plant_id', 'nrems-03') \ + .eq('date', '2026-05-14') \ + .execute() +for row in resp2.data: + print(f" {row}") + +# 3. alert_history ๋กœ์ปฌ DB ํ™•์ธ +print("\n" + "=" * 60) +print("[3] ๋กœ์ปฌ alert_history (crawler_manager.db)") +print("=" * 60) +db_path = Path(__file__).parent.parent / "crawler_manager.db" +try: + with sqlite3.connect(str(db_path)) as conn: + cur = conn.cursor() + cur.execute("SELECT site_id, alert_status, last_alert_time FROM alert_history WHERE site_id='nrems-03'") + row = cur.fetchone() + if row: + print(f" site_id={row[0]} status={row[1]} last_alert={row[2]}") + else: + print(" nrems-03 alert_history ์—†์Œ") +except Exception as e: + print(f" ๋กœ์ปฌ DB ์ฝ๊ธฐ ์‹คํŒจ: {e} (์„œ๋ฒ„ DB๋Š” ์„œ๋ฒ„์— ์žˆ์Œ)") diff --git a/docs/check.md b/docs/check.md new file mode 100644 index 0000000..4baeb00 --- /dev/null +++ b/docs/check.md @@ -0,0 +1,48 @@ +[ํ”„๋กœ์ ํŠธ ๊ฐœ์š”] +- ํƒœ์–‘๊ด‘ ๋ฐœ์ „์†Œ ํ†ตํ•ฉ ๋ชจ๋‹ˆํ„ฐ๋ง ํ”Œ๋žซํผ ๊ตฌ์ถ• + +[ํ”„๋กœ์ ํŠธ ๋ฐฐ๊ฒฝ ๋ฐ ๋ชฉํ‘œ] +- ๋‹น์‚ฌ๋Š” ํƒœ์–‘๊ด‘ ๋ฐœ์ „์†Œ ์‹œ๊ณต ๋ฐ ๊ด€๋ฆฌ๋ฅผ ์ „๋ฌธ์œผ๋กœ ํ•˜๋Š” ๊ธฐ์—…์ž…๋‹ˆ๋‹ค. ํ˜„์žฌ ๊ฐ ๋ฐœ์ „์†Œ์— ์„ค์น˜๋œ ์žฅ๋น„ ์ œ์กฐ์‚ฌ๋ณ„๋กœ ๋ชจ๋‹ˆํ„ฐ๋ง ์›น์‚ฌ์ดํŠธ๊ฐ€ ๋‹ฌ๋ผ, ๋ฐœ์ „๋Ÿ‰ ๋ฐ์ดํ„ฐ๋ฅผ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์—ฌ๋Ÿฌ ์‚ฌ์ดํŠธ๋ฅผ ๊ฐœ๋ณ„์ ์œผ๋กœ ์ ‘์†ํ•ด์•ผ ํ•˜๋Š” ๋ฒˆ๊ฑฐ๋กœ์›€์ด ์žˆ์Šต๋‹ˆ๋‹ค. +์ด์—, ๋ถ„์‚ฐ๋œ ์—ฌ๋Ÿฌ ์ œ์กฐ์‚ฌ์˜ ๋ชจ๋‹ˆํ„ฐ๋ง ์‚ฌ์ดํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ํ•˜๋‚˜์˜ ํ”Œ๋žซํผ์œผ๋กœ ํ†ตํ•ฉํ•˜์—ฌ ๋ณด์—ฌ์ฃผ๋Š” ์‹œ์Šคํ…œ์„ ๊ตฌ์ถ•ํ•˜๊ณ ์ž ํ•ฉ๋‹ˆ๋‹ค. +1์ฐจ์ ์œผ๋กœ๋Š” ํ†ตํ•ฉ ๋ชจ๋‹ˆํ„ฐ๋ง ๊ธฐ๋Šฅ์— ์ง‘์ค‘ํ•˜๊ณ , ์žฅ๊ธฐ์ ์œผ๋กœ๋Š” ๊ณต์žฅ ์—๋„ˆ์ง€ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ(EMS) ๋ฐ ๊ฐ€์ƒ๋ฐœ์ „์†Œ(VPP) ๊ธฐ๋Šฅ๊นŒ์ง€ ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ๊ธฐ๋ฐ˜์„ ๋งˆ๋ จํ•˜๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. + +[๊ณผ์—… ๋ฒ”์œ„] +1. ์ˆ˜ํ–‰ ๋ฒ”์œ„ +- ์ƒ์„ธ ๊ธฐํš: ์š”๊ตฌ ์‚ฌํ•ญ ์ •์˜, ๊ธฐ๋Šฅ ์ •์˜, ํ™”๋ฉด ์„ค๊ณ„ +- UI/UX ๋””์ž์ธ +- ํ”„๋ŸฐํŠธ์—”๋“œ/Client ๊ฐœ๋ฐœ (PC Web) +- ๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ +- ์„œ๋ฒ„/DB/์ธํ”„๋ผ ๊ตฌ์„ฑ + +2. ์ƒ์„ธ ๊ธฐ๋Šฅ ์š”๊ตฌ ์‚ฌํ•ญ +2-1. ํ†ตํ•ฉ ๋ชจ๋‹ˆํ„ฐ๋ง ๋Œ€์‹œ๋ณด๋“œ: ์‚ฌ์šฉ์ž๊ฐ€ ๋“ฑ๋กํ•œ ๋ชจ๋“  ๋ฐœ์ „์†Œ์˜ ๋ฐœ์ „๋Ÿ‰, ์ƒํƒœ ๋“ฑ ํ•ต์‹ฌ ๋ฐ์ดํ„ฐ๋ฅผ ํ•œ ํ™”๋ฉด์—์„œ ์ง๊ด€์ ์œผ๋กœ ํŒŒ์•…ํ•  ์ˆ˜ ์žˆ๋Š” ๋Œ€์‹œ๋ณด๋“œ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +2-2. ๋ฐœ์ „์†Œ ๋ฐ ๋ฐ์ดํ„ฐ ์—ฐ๋™ ๊ด€๋ฆฌ: ์‚ฌ์šฉ์ž๊ฐ€ ๊ฐ ์ œ์กฐ์‚ฌ๋ณ„ ๋ชจ๋‹ˆํ„ฐ๋ง ์‚ฌ์ดํŠธ์˜ ๊ณ„์ • ์ •๋ณด(ID/PW)๋ฅผ ์ž…๋ ฅํ•˜์—ฌ ์ž์‹ ์˜ ๋ฐœ์ „์†Œ๋ฅผ ํ”Œ๋žซํผ์— ๋“ฑ๋กํ•˜๊ณ , ๋“ฑ๋ก๋œ ์ •๋ณด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์‹œ์Šคํ…œ์ด ์ž๋™์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์—ฐ๋™(์ˆ˜์ง‘)ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. +2-3. ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ ์‹œ์Šคํ…œ: ํ”Œ๋žซํผ์„ ์‚ฌ์šฉํ•˜๋Š” ๊ณ ๊ฐ์‚ฌ๊ฐ€ ์ง์ ‘ ํšŒ์›๊ฐ€์ž…, ๋กœ๊ทธ์ธ, ์ •๋ณด ์ˆ˜์ •์„ ํ•  ์ˆ˜ ์žˆ๋Š” ํšŒ์› ๊ด€๋ฆฌ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +2-4. ๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€ ๊ธฐ๋Šฅ: ์ „์ฒด ์‚ฌ์šฉ์ž ๊ณ„์ • ๊ด€๋ฆฌ, ๋ฐ์ดํ„ฐ ์—ฐ๋™ ์ƒํƒœ ๋ชจ๋‹ˆํ„ฐ๋ง, ์‹œ์Šคํ…œ ๊ณต์ง€์‚ฌํ•ญ ๋“ฑ๋ก ๋“ฑ ํ”Œ๋žซํผ ์šด์˜์„ ์œ„ํ•œ ํฌ๊ด„์ ์ธ ๊ด€๋ฆฌ์ž ๊ธฐ๋Šฅ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. + +[์ง€์› ๋””๋ฐ”์ด์Šค ๋ฐ ๋””์ž์ธ] +1. ์ง€์› ๋””๋ฐ”์ด์Šค +- PC Web (๋ฐ˜์‘ํ˜• ์ง€์›) +2. ๋””์ž์ธ ๊ฐ€์ด๋“œ +- ๋ฒค์น˜๋งˆํ‚น ๋Œ€์ƒ: ๋ฐœ์ „์™• +- ๋ธŒ๋žœ๋“œ ๊ฐ€์ด๋“œ๋ผ์ธ: ์ž์‚ฌ ๋ธŒ๋žœ๋”ฉ ์ ์šฉ(ํ™”์ดํŠธ ๋ผ๋ฒจ๋ง ๋ฐฉ์‹)์„ ์œ„ํ•œ ํ˜‘์˜ ํ•„์š” + +[๊ธฐ์ˆ  ์Šคํƒ] +ํ”„๋กœ์ ํŠธ์— ๊ฐ€์žฅ ์ ํ•ฉํ•˜๋‹ค๊ณ  ํŒ๋‹จ๋˜๋Š” ๊ธฐ์ˆ  ์Šคํƒ์„ ์ž์œ ๋กญ๊ฒŒ ์ œ์•ˆํ•ด ์ฃผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. + +[์ง€์› ์ž๊ฒฉ ๋ฐ ์šฐ๋Œ€ ์‚ฌํ•ญ] +1. ์ง€์› ์ž๊ฒฉ +- ์œ ์‚ฌ ํ†ตํ•ฉ ๋ชจ๋‹ˆํ„ฐ๋ง ํ”Œ๋žซํผ ๊ฐœ๋ฐœ ๊ฒฝํ—˜ +- ์—๋„ˆ์ง€ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ(EMS) ๋˜๋Š” ๊ด€๋ จ ํ”Œ๋žซํผ ๊ฐœ๋ฐœ ๊ฒฝํ—˜ ๋ณด์œ  ์—…์ฒด +2. ์šฐ๋Œ€ ์‚ฌํ•ญ +- ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ ์—ฐ๋™ ๋ฐ ํŒŒ์‹ฑ ๊ด€๋ จ ๊ธฐ์ˆ  ๊ฒฝํ—˜ +- ์žฅ๊ธฐ์ ์ธ ๊ด€์ ์—์„œ ํ˜‘๋ ฅ ๋ฐ ์‹œ์Šคํ…œ ๊ณ ๋„ํ™”๊ฐ€ ๊ฐ€๋Šฅํ•œ ์—…์ฒด + +[์‚ฐ์ถœ๋ฌผ] +- ์ƒ์„ธ ๊ธฐํš์„œ (IA, ๊ธฐ๋Šฅ์ •์˜์„œ, ํ™”๋ฉด์„ค๊ณ„์„œ ๋“ฑ) +- UI/UX ๋””์ž์ธ ์›๋ณธ ํŒŒ์ผ (Figma, XD ๋“ฑ) +- ๊ฐœ๋ฐœ ์†Œ์Šค์ฝ”๋“œ ์ผ์ฒด +- ์„œ๋ฒ„ ๊ตฌ์„ฑ ๋ฐ ์ธํ”„๋ผ ๊ด€๋ จ ๋ช…์„ธ์„œ + +[๊ณ„์•ฝ ๊ด€๋ จ ํŠน์ด ์‚ฌํ•ญ] +- ๋ณธ ํ”„๋กœ์ ํŠธ๋Š” 1๋‹จ๊ณ„ ์‚ฌ์—…์ด๋ฉฐ, ํ–ฅํ›„ ์—๋„ˆ์ง€ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ(EMS), ์ „๋ ฅ ์ค‘๊ฐœ, ๊ฐ€์ƒ๋ฐœ์ „์†Œ(VPP) ๋“ฑ์œผ๋กœ์˜ ๋‹จ๊ณ„์  ๊ธฐ๋Šฅ ๊ณ ๋„ํ™”๋ฅผ ๊ณ„ํšํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์žฅ๊ธฐ์ ์ธ ํŒŒํŠธ๋„ˆ์‹ญ์„ ๊ณ ๋ คํ•˜์—ฌ ์ œ์•ˆํ•ด ์ฃผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. diff --git a/docs/platform_analysis_report.md b/docs/platform_analysis_report.md new file mode 100644 index 0000000..ebda596 --- /dev/null +++ b/docs/platform_analysis_report.md @@ -0,0 +1,315 @@ +# ํƒœ์–‘๊ด‘ ๋ฐœ์ „์†Œ ํ†ตํ•ฉ ๋ชจ๋‹ˆํ„ฐ๋ง ํ”Œ๋žซํผ โ€” ์ข…ํ•ฉ ๋ถ„์„ ๋ณด๊ณ ์„œ + +> ์ž‘์„ฑ์ผ: 2026-04-22 +> ์ฐธ์กฐ ๋ฌธ์„œ: `docs/check.md` (๊ณผ์—… ์š”๊ตฌ์‚ฌํ•ญ), `README.md` (ํ˜„ ์‹œ์Šคํ…œ ํ˜„ํ™ฉ) + +--- + +## 1. ํ”„๋กœ์ ํŠธ ์ „์ฒด์ƒ (Vision) + +### 1-1. ํ˜„์žฌ ์‹œ์Šคํ…œ vs. ๋ชฉํ‘œ ์‹œ์Šคํ…œ + +| ๊ตฌ๋ถ„ | ํ˜„์žฌ SolarPower | ๋ชฉํ‘œ ํ”Œ๋žซํผ (check.md) | +|------|----------------|----------------------| +| **์‚ฌ์šฉ ์ฃผ์ฒด** | ๋‚ด๋ถ€ ๊ด€๋ฆฌ์ž 1์ธ | ๋‹ค์ˆ˜์˜ ์™ธ๋ถ€ ๊ณ ๊ฐ์‚ฌ (B2B) | +| **๋ฐœ์ „์†Œ ์ˆ˜** | ์•ฝ 9๊ฐœ (๊ณ ์ •) | ๋ฌด์ œํ•œ (์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ๋“ฑ๋ก) | +| **ํฌ๋กค๋Ÿฌ ๊ด€๋ฆฌ** | ๊ฐœ๋ฐœ์ž๊ฐ€ ์ง์ ‘ ์ฝ”๋”ฉ | ์‚ฌ์šฉ์ž ๊ณ„์ • ๊ธฐ๋ฐ˜ ์ž๋™ ์ƒ์„ฑ | +| **UI** | React Native App (๋ชจ๋ฐ”์ผ ์ค‘์‹ฌ) | PC Web ๋ฐ˜์‘ํ˜• (๋ฐœ์ „์™• ์ˆ˜์ค€) | +| **์ธํ”„๋ผ ๊ฒฉ๋ฆฌ** | ๋‹จ์ผ ํ…Œ๋„ŒํŠธ | ๋ฉ€ํ‹ฐ ํ…Œ๋„ŒํŠธ (๊ณ ๊ฐ์‚ฌ ๊ฐ„ ๋ฐ์ดํ„ฐ ์™„์ „ ๊ฒฉ๋ฆฌ) | +| **ํ™•์žฅ ๋ชฉํ‘œ** | ํ˜„ํ–‰ ๋ชจ๋‹ˆํ„ฐ๋ง ์œ ์ง€ | EMS, VPP๊นŒ์ง€ ๋‹จ๊ณ„์  ํ™•์žฅ | + +### 1-2. ์ œ์•ˆ ์•„ํ‚คํ…์ฒ˜ ๊ฐœ์š” + +```mermaid +flowchart TD + subgraph CLIENT["ํด๋ผ์ด์–ธํŠธ (PC Web)"] + USER["์ผ๋ฐ˜ ์‚ฌ์šฉ์ž\n(๋ฐœ์ „์†Œ ๋“ฑ๋ก/๋ชจ๋‹ˆํ„ฐ๋ง)"] + ADMIN["๊ด€๋ฆฌ์ž\n(๊ณ„์ •/์‹œ์Šคํ…œ ๊ด€๋ฆฌ)"] + end + + subgraph PLATFORM["ํ”Œ๋žซํผ ์ฝ”์–ด (Oracle Cloud)"] + NEXTJS["Next.js\nFrontend (SSR)"] + FASTAPI["FastAPI\nBackend (REST API)"] + SCHEDULER["Crawler Scheduler\n(์‚ฌ์šฉ์ž๋ณ„ ๋…๋ฆฝ ์Šค์ผ€์ค„)"] + SECRET_MGR["Secret Manager\n(AES-256 ์•”ํ˜ธํ™”)"] + QUEUE["Task Queue\n(Redis/Celery)"] + end + + subgraph CRAWLERS["์ˆ˜์ง‘ ๋ ˆ์ด์–ด"] + PROXY["Residential Proxy Pool\n(IP ์ฐจ๋‹จ ์šฐํšŒ)"] + HEADLESS["Headless Browser\nCluster (Playwright)"] + end + + subgraph EXTERNAL["์™ธ๋ถ€ ์ œ์กฐ์‚ฌ ์‹œ์Šคํ…œ"] + NREMS["NREMS"] + KREMC["KREMC"] + SUNWMS["Sun-WMS"] + ETC["๊ธฐํƒ€ ์ œ์กฐ์‚ฌ..."] + end + + subgraph DATA["๋ฐ์ดํ„ฐ ๋ ˆ์ด์–ด"] + SUPABASE["Supabase\n(PostgreSQL + RLS)"] + TIMESERIES["์‹œ๊ณ„์—ด DB\n(ํ–ฅํ›„ EMS/VPP์šฉ)"] + end + + USER --> NEXTJS + ADMIN --> NEXTJS + NEXTJS --> FASTAPI + FASTAPI --> SECRET_MGR + FASTAPI --> SCHEDULER + SCHEDULER --> QUEUE + QUEUE --> HEADLESS + HEADLESS --> PROXY + PROXY --> NREMS & KREMC & SUNWMS & ETC + FASTAPI --> SUPABASE + HEADLESS --> SUPABASE +``` + +--- + +## 2. ๊ธฐ๋Šฅ๋ณ„ ์œ„ํ—˜ ์š”์†Œ ๋ฐ ํ•ด๊ฒฐ ๋ฐฉ์•ˆ + +### 2-1. ํ•ต์‹ฌ ๊ธฐ๋Šฅ: ๋ฐœ์ „์†Œ ๋“ฑ๋ก ๋ฐ ์ž๋™ ๋ฐ์ดํ„ฐ ์—ฐ๋™ + +์ด ๊ธฐ๋Šฅ์€ ์ „์ฒด ํ”„๋กœ์ ํŠธ์—์„œ **๊ธฐ์ˆ ์  ๋‚œ์ด๋„์™€ ๋ฆฌ์Šคํฌ๊ฐ€ ๊ฐ€์žฅ ๋†’์€** ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค. + +#### ๐Ÿ”ด ์œ„ํ—˜ 1: ์‚ฌ์šฉ์ž ๊ณ„์ • ์ •๋ณด(ID/PW) ๋ณด์•ˆ + +**๋ฌธ์ œ:** +- ํƒ€์‚ฌ ์„œ๋น„์Šค์˜ ํ‰๋ฌธ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ DB์— ์ €์žฅํ•˜๋Š” ๊ฒƒ์€ ๋ฒ•์ ยท์œค๋ฆฌ์  ๋ฆฌ์Šคํฌ๋ฅผ ๋‚ดํฌํ•ฉ๋‹ˆ๋‹ค. +- ์„œ๋ฒ„ ์นจํ•ด ์‹œ ์‚ฌ์šฉ์ž ๊ณ„์ • ์œ ์ถœ๋กœ ์ธํ•œ 2์ฐจ ํ”ผํ•ด ๋ฐ ๋ฐฐ์ƒ ์ฑ…์ž„์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. +- ๊ฐœ์ธ์ •๋ณด๋ณดํ˜ธ๋ฒ• ๋ฐ ์ •๋ณดํ†ต์‹ ๋ง๋ฒ• ์œ„๋ฐ˜ ์†Œ์ง€๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. + +**ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:** +``` +1. ์–‘๋ฐฉํ–ฅ ์•”ํ˜ธํ™” ํ•„์ˆ˜ ์ ์šฉ + - ์ €์žฅ: AES-256-GCM ์•”ํ˜ธํ™” ํ›„ DB ์ €์žฅ + - ๋ณตํ˜ธํ™” ํ‚ค: ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋ฒ„ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๋˜๋Š” ์ „์šฉ KMS(Key Management Service)์— ๋ถ„๋ฆฌ ๋ณด๊ด€ + - DB ์œ ์ถœ ์‹œ์—๋„ ์•”ํ˜ธํ™” ํ‚ค ์—†์ด๋Š” ๋ณตํ˜ธํ™” ๋ถˆ๊ฐ€ + +2. ์ €์žฅ ๋ฒ”์œ„ ์ตœ์†Œํ™” + - ์ตœ์ดˆ ๋กœ๊ทธ์ธ ์„ฑ๊ณต ํ›„ ์„ธ์…˜ ์ฟ ํ‚ค/ํ† ํฐ๋งŒ ์ €์žฅํ•˜๊ณ  ์›๋ณธ PW๋Š” ๋ฉ”๋ชจ๋ฆฌ์—์„œ ์ฆ‰์‹œ ํŒŒ๊ธฐ + - ํ† ํฐ ๊ฐฑ์‹  ์ฃผ๊ธฐ์—๋งŒ PW ์žฌ์‚ฌ์šฉ (์ ‘๊ทผ ๋นˆ๋„ ์ตœ์†Œํ™”) + +3. ์‚ฌ์šฉ์ž ๋™์˜ ๋ฐ ์•ฝ๊ด€ + - ๊ณ„์ • ์ •๋ณด ์œ„ํƒ ๋ณด๊ด€์— ๋Œ€ํ•œ ๋ช…์‹œ์  ๋™์˜ ๋ฐ›๊ธฐ + - ๊ฐœ์ธ์ •๋ณด ์ฒ˜๋ฆฌ ๋ฐฉ์นจ์— ํ•ด๋‹น ๋‚ด์šฉ ๋ช…๊ธฐ +``` + +#### ๐Ÿ”ด ์œ„ํ—˜ 2: ์ œ์กฐ์‚ฌ ์‚ฌ์ดํŠธ์˜ ์ž๋™ํ™” ํƒ์ง€ ๋ฐ ์ฐจ๋‹จ + +**๋ฌธ์ œ:** +- ๋‹ค์ˆ˜์˜ ๊ณ„์ •์ด ๋™์ผ IP์—์„œ ์งง์€ ์‹œ๊ฐ„ ๋‚ด ๋กœ๊ทธ์ธ โ†’ ๋ณด์•ˆ ์‹œ์Šคํ…œ์— ์˜ํ•ด IP ์ฐจ๋‹จ +- ์‚ฌ์šฉ์ž ๊ณ„์ •์ด ์ž๋™ ์ž ๊ธˆ(Lock) ์ฒ˜๋ฆฌ๋˜๋Š” ์ตœ์•…์˜ UX ๋ฐœ์ƒ +- 2๋‹จ๊ณ„ ์ธ์ฆ(MFA), ์บก์ฐจ(CAPTCHA) ๋“ฑ์˜ ๋ฐฉ์–ด ๊ธฐ์ œ + +**ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:** +``` +1. ๋ถ„์‚ฐ ํ”„๋ก์‹œ ์ „๋žต + - Residential Proxy Pool ์‚ฌ์šฉ (์‹ค์ œ ๊ฐ€์ •์šฉ IP ํ™œ์šฉ) + - ์‚ฌ์šฉ์ž๋ณ„ ๊ณ ์ • ํ”„๋ก์‹œ IP ํ• ๋‹น (ํ•œ IP = ํ•œ ๊ณ„์ • ์›์น™) + - ์˜ˆ์‹œ ์„œ๋น„์Šค: Bright Data, Oxylabs, IPRoyal + +2. ์ธ๊ฐ„ ํ–‰๋™ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ + - requests ๋ฐฉ์‹ โ†’ Playwright/Puppeteer ํ—ค๋“œ๋ฆฌ์Šค ๋ธŒ๋ผ์šฐ์ €๋กœ ์ „ํ™˜ + - ๋กœ๊ทธ์ธ ๊ฐ„๊ฒฉ์„ ๋žœ๋คํ•˜๊ฒŒ ์„ค์ • (1~5๋ถ„ ์ง€์—ฐ) + - User-Agent, ์ฟ ํ‚ค, ์„ธ์…˜ ํ—ค๋” ์™„์ „ ๋ณต์ œ + +3. ์„ธ์…˜ ์บ์‹ฑ ์ „๋žต + - ๋กœ๊ทธ์ธ ์„ฑ๊ณต ํ›„ ์„ธ์…˜ ์ฟ ํ‚ค๋ฅผ Redis์— ์บ์‹ฑํ•˜์—ฌ ์žฌํ™œ์šฉ + - ์„ธ์…˜ ๋งŒ๋ฃŒ ์ง์ „์—๋งŒ ์žฌ๋กœ๊ทธ์ธ ์ˆ˜ํ–‰ (๋กœ๊ทธ์ธ ๋นˆ๋„ ์ตœ์†Œํ™”) + +4. ์‹คํŒจ ๋Œ€์‘ UX + - ์ˆ˜์ง‘ ์‹คํŒจ ๊ฐ์ง€ ์ฆ‰์‹œ โ†’ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ด๋ฉ”์ผ/์•ฑ ์•Œ๋ฆผ + - "๊ณ„์ • ์ •๋ณด๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•ด ์ฃผ์„ธ์š”" UI ์•ˆ๋‚ด ํ๋ฆ„ +``` + +#### ๐ŸŸก ์œ„ํ—˜ 3: ์ œ์กฐ์‚ฌ๋ณ„ ํฌ๋กค๋Ÿฌ ์œ ์ง€๋ณด์ˆ˜ ๋ถ€๋‹ด + +**๋ฌธ์ œ:** +- ์ œ์กฐ์‚ฌ ์‚ฌ์ดํŠธ UI ๋ณ€๊ฒฝ ์‹œ ํฌ๋กค๋Ÿฌ ์ฆ‰์‹œ ์ค‘๋‹จ +- ์ง€์› ์ œ์กฐ์‚ฌ ์ˆ˜ ์ฆ๊ฐ€์— ๋”ฐ๋ผ ์œ ์ง€๋ณด์ˆ˜ ๊ณต์ˆ˜๊ฐ€ ์„ ํ˜• ์ฆ๊ฐ€ + +**ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:** +``` +1. ํฌ๋กค๋Ÿฌ ํ”Œ๋Ÿฌ๊ทธ์ธ ์•„ํ‚คํ…์ฒ˜ ๋„์ž… + - ์ œ์กฐ์‚ฌ๋ณ„ ํฌ๋กค๋Ÿฌ๋ฅผ ๋…๋ฆฝ ๋ชจ๋“ˆ(์–ด๋Œ‘ํ„ฐ ํŒจํ„ด)๋กœ ๋ถ„๋ฆฌ + - ๊ณตํ†ต ์ธํ„ฐํŽ˜์ด์Šค(BaseCrawler) ์ƒ์†์œผ๋กœ ์‹ ๊ทœ ์ œ์กฐ์‚ฌ ์ถ”๊ฐ€ ์šฉ์ด + +2. ์ž๋™ ํ—ฌ์Šค์ฒดํฌ ์‹œ์Šคํ…œ + - ๋งค ์ˆ˜์ง‘ ํ›„ ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ(๋‚ ์งœ, ์ˆ˜์น˜ ๋ฒ”์œ„) ์ž๋™ ๊ฒ€์ฆ + - ์—ฐ์† 3ํšŒ ์‹คํŒจ ์‹œ ๊ด€๋ฆฌ์ž์—๊ฒŒ ์ž๋™ ์•Œ๋žŒ ๋ฐœ์†ก + +3. ํ˜„ํ–‰ SolarPower ํฌ๋กค๋Ÿฌ ์žฌํ™œ์šฉ + - NREMS, KREMC, Sun-WMS, Hyundai, CMSolar ํฌ๋กค๋Ÿฌ๋Š” ์ด๋ฏธ ๊ฒ€์ฆ๋จ + - ๊ธฐ์กด ์ฝ”๋“œ๋ฅผ ๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ ๊ตฌ์กฐ๋กœ ๋ž˜ํ•‘ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋น ๋ฅด๊ฒŒ ํ™•์žฅ ๊ฐ€๋Šฅ +``` + +--- + +### 2-2. ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ ๋ฐ ๋ฐ์ดํ„ฐ ๊ฒฉ๋ฆฌ + +#### ๐Ÿ”ด ์œ„ํ—˜: ๊ณ ๊ฐ์‚ฌ ๊ฐ„ ๋ฐ์ดํ„ฐ ํ˜ผ์žฌ + +**๋ฌธ์ œ:** +- ํ˜„์žฌ ๊ตฌ์กฐ๋Š” ๋‹จ์ผ ์‚ฌ์šฉ์ž ๊ฐ€์ • ํ•˜์— ์„ค๊ณ„๋จ +- ๋‹ค์ˆ˜ ๊ณ ๊ฐ์‚ฌ๊ฐ€ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ ๋ฐ์ดํ„ฐ๊ฐ€ ์„ž์ผ ์œ„ํ—˜ + +**ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:** +``` +1. Supabase Row Level Security (RLS) ์ ์šฉ + - ๋ชจ๋“  ํ…Œ์ด๋ธ”์— tenant_id ์ปฌ๋Ÿผ ์ถ”๊ฐ€ + - RLS ์ •์ฑ…: "์ž์‹ ์˜ tenant_id์— ํ•ด๋‹นํ•˜๋Š” ํ–‰๋งŒ SELECT/INSERT/UPDATE ๊ฐ€๋Šฅ" + - DB ๋ ˆ๋ฒจ์—์„œ ๊ฒฉ๋ฆฌ ๋ณด์žฅ โ†’ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ฒ„๊ทธ๋กœ๋„ ํƒ€ ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ ์ ‘๊ทผ ๋ถˆ๊ฐ€ + +2. ๋ฐ์ดํ„ฐ ๋ชจ๋ธ ์žฌ์„ค๊ณ„ ํ•„์š” + ํ˜„์žฌ: plants (plant_id, name, ...) + ๋ณ€๊ฒฝ: plants (plant_id, tenant_id, name, ...) โ† tenant_id FK ์ถ”๊ฐ€ + credentials (id, tenant_id, plant_id, + encrypted_id, encrypted_pw, ...) โ† ์‹ ๊ทœ ํ…Œ์ด๋ธ” (์•”ํ˜ธํ™” ๊ณ„์ • ์ €์žฅ) +``` + +--- + +### 2-3. ํ”„๋ก ํŠธ์—”๋“œ ์•„ํ‚คํ…์ฒ˜ (PC Web) + +#### ๐ŸŸก ์œ„ํ—˜: Expo Web์˜ B2B ๋Œ€์‹œ๋ณด๋“œ ํ•œ๊ณ„ + +**๋ฌธ์ œ:** +- ํ˜„์žฌ React Native(Expo) ๊ธฐ๋ฐ˜์€ ๋ชจ๋ฐ”์ผ UX ์ค‘์‹ฌ +- '๋ฐœ์ „์™•' ์ˆ˜์ค€์˜ ๋ณต์žกํ•œ PC ๋ฐ์ดํ„ฐ ๋Œ€์‹œ๋ณด๋“œ ๊ตฌํ˜„์— Expo๋Š” ์ ํ•ฉํ•˜์ง€ ์•Š์Œ +- ๋ณต์žกํ•œ ๊ทธ๋ฆฌ๋“œ, ๋ฐ์ดํ„ฐ ํ…Œ์ด๋ธ”, ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ์ฐจํŠธ๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์–ด๋ ค์›€ + +**ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:** +``` +๊ถŒ์žฅ ํ”„๋ก ํŠธ์—”๋“œ ์Šคํƒ: +- Framework: Next.js 14 (App Router, SSR/ISR ์ง€์›) +- Styling: TailwindCSS + shadcn/ui (B2B ๋Œ€์‹œ๋ณด๋“œ ์ปดํฌ๋„ŒํŠธ) +- Charts: Recharts ๋˜๋Š” Apache ECharts (๊ณ ์„ฑ๋Šฅ ์‹œ๊ณ„์—ด ์ฐจํŠธ) +- State: Zustand + React Query (์„œ๋ฒ„ ์ƒํƒœ ์บ์‹ฑ) + +๊ธฐ์กด Expo ์•ฑ์€ ๋ชจ๋ฐ”์ผ ์•Œ๋ฆผ ์ˆ˜์‹ ์šฉ์œผ๋กœ ์œ ์ง€ํ•˜๊ณ , +PC Web์€ Next.js๋กœ ๋ณ„๋„ ๊ตฌ์ถ•ํ•˜๋Š” ์ด์›ํ™” ์ „๋žต ์ถ”์ฒœ +``` + +--- + +### 2-4. ์ธํ”„๋ผ ํ™•์žฅ์„ฑ (ํฌ๋กค๋ง ์„œ๋ฒ„) + +#### ๐ŸŸก ์œ„ํ—˜: ๋‹จ์ผ Oracle Cloud ์ธ์Šคํ„ด์Šค์˜ ํ•œ๊ณ„ + +**๋ฌธ์ œ:** +- ๊ณ ๊ฐ์‚ฌ ์ˆ˜ ์ฆ๊ฐ€ โ†’ ๋™์‹œ ํฌ๋กค๋ง ์ž‘์—… ๊ธ‰์ฆ +- ๋‚˜์Šค์˜ ๋‹จ์ผ IP๋ฅผ Exit Node๋กœ ์‚ฌ์šฉ โ†’ IP ์ฐจ๋‹จ ์‹œ ์ „์ฒด ์„œ๋น„์Šค ์ค‘๋‹จ + +**ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:** +``` +๋‹จ๊ณ„๋ณ„ ์ธํ”„๋ผ ์ „๋žต: + +[1๋‹จ๊ณ„ - ์†Œ๊ทœ๋ชจ, ~50๊ฐœ ๋ฐœ์ „์†Œ] + ํ˜„ํ–‰ Oracle Cloud + Tailscale(๋‚˜์Šค) ๊ตฌ์กฐ ์œ ์ง€ + Celery + Redis๋กœ ํฌ๋กค๋ง ํƒœ์Šคํฌ ํ ๊ด€๋ฆฌ + +[2๋‹จ๊ณ„ - ์ค‘๊ทœ๋ชจ, ~300๊ฐœ ๋ฐœ์ „์†Œ] + Residential Proxy Pool ๋„์ž… (IP ์ฐจ๋‹จ ์šฐํšŒ) + ํฌ๋กค๋ง ์„œ๋ฒ„ ์ธ์Šคํ„ด์Šค ์ˆ˜ํ‰ ํ™•์žฅ (2~3๋Œ€) + +[3๋‹จ๊ณ„ - ๋Œ€๊ทœ๋ชจ, 300๊ฐœ ์ด์ƒ] + Kubernetes ๊ธฐ๋ฐ˜ ํฌ๋กค๋Ÿฌ ํŒŒ๋“œ ์ž๋™ ์Šค์ผ€์ผ๋ง + ์ œ์กฐ์‚ฌ๋ณ„ ์ „์šฉ ํฌ๋กค๋Ÿฌ ์›Œ์ปค ๋ถ„๋ฆฌ +``` + +--- + +## 3. ๋ฒ•์ ยท์‚ฌ์—…์  ๋ฆฌ์Šคํฌ + +### 3-1. ์ œ์กฐ์‚ฌ ์„œ๋น„์Šค ์•ฝ๊ด€ ์œ„๋ฐ˜ + +| ์œ„ํ—˜ ์ˆ˜์ค€ | ๋‚ด์šฉ | ๋Œ€์‘ | +|-----------|------|------| +| ๐Ÿ”ด ๋†’์Œ | ์ œ์กฐ์‚ฌ ToS์˜ ์ž๋™ํ™” ์ ‘๊ทผ ๊ธˆ์ง€ ์กฐํ•ญ ์œ„๋ฐ˜ | ์„œ๋น„์Šค ์ดˆ๊ธฐ์— ์ œ์กฐ์‚ฌ์— ํ˜‘๋ ฅ ์ œ์•ˆ ๊ฒ€ํ†  | +| ๐ŸŸก ์ค‘๊ฐ„ | ์Šคํฌ๋ž˜ํ•‘ ํ–‰์œ„ ์ž์ฒด์˜ ๋ฒ•์  ํšŒ์ƒ‰ ์ง€๋Œ€ | ๋ฒ•๋ฌด ๊ฒ€ํ†  ํ›„ ์ด์šฉ์•ฝ๊ด€์— ๋ฉด์ฑ… ์กฐํ•ญ ์ถ”๊ฐ€ | +| ๐ŸŸข ๋‚ฎ์Œ | ์ˆ˜์ง‘ ๋ฐ์ดํ„ฐ ์ €์ž‘๊ถŒ ๋ถ„์Ÿ | ๊ฐ€๊ณต๋œ ํ†ต๊ณ„ ๋ฐ์ดํ„ฐ๋งŒ ๋…ธ์ถœ, ์›๋ฌธ ๋ฏธ์žฌํ˜„ | + +### 3-2. ๊ฐœ์ธ์ •๋ณด ์ฒ˜๋ฆฌ + +``` +ํ•„์ˆ˜ ์กฐ์น˜: +- ๊ฐœ์ธ์ •๋ณด ์ฒ˜๋ฆฌ ๋ฐฉ์นจ ์ˆ˜๋ฆฝ (๊ฐœ์ธ์ •๋ณด๋ณดํ˜ธ๋ฒ• ์ค€์ˆ˜) +- ํƒ€์‚ฌ ๊ณ„์ • ์ •๋ณด ์œ„ํƒ ์ฒ˜๋ฆฌ์— ๋Œ€ํ•œ ๋ณ„๋„ ๋™์˜ ์ ˆ์ฐจ +- ๊ฐœ์ธ์ •๋ณด ๋ณดํ˜ธ์ฑ…์ž„์ž(CPO) ์ง€์ • +- ์—ฐ 1ํšŒ ์ด์ƒ ๋ณด์•ˆ ์ทจ์•ฝ์  ์ ๊ฒ€ +``` + +--- + +## 4. ๊ถŒ์žฅ ๊ธฐ์ˆ  ์Šคํƒ + +| ์˜์—ญ | ํ˜„์žฌ | ๊ถŒ์žฅ (ํ”Œ๋žซํผ) | ์ด์œ  | +|------|------|-------------|------| +| **Frontend** | React Native (Expo) | **Next.js 14** | SSR, B2B ๋Œ€์‹œ๋ณด๋“œ ์ตœ์ ํ™” | +| **Backend** | FastAPI | **FastAPI** ์œ ์ง€ | ๊ฒ€์ฆ๋œ ๊ตฌ์กฐ ์žฌํ™œ์šฉ ๊ฐ€๋Šฅ | +| **Database** | Supabase (PostgreSQL) | **Supabase** ์œ ์ง€ + RLS ๊ฐ•ํ™” | ๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ RLS ๊ธฐ๋Šฅ ๋‚ด์žฅ | +| **ํฌ๋กค๋Ÿฌ** | Python requests | **Playwright** (ํ—ค๋“œ๋ฆฌ์Šค) | ์ž๋™ํ™” ํƒ์ง€ ์šฐํšŒ | +| **๋น„๋™๊ธฐ ํ** | ์—†์Œ (Cron) | **Celery + Redis** | ๋‹ค์ˆ˜ ๊ณ„์ • ๋™์‹œ ์ˆ˜์ง‘ ๊ด€๋ฆฌ | +| **์•”ํ˜ธํ™”** | ์—†์Œ | **AES-256-GCM + KMS** | ๊ณ„์ • ์ •๋ณด ์•ˆ์ „ ๋ณด๊ด€ | +| **ํ”„๋ก์‹œ** | Tailscale (๋‚˜์Šค) | **Residential Proxy Pool** | IP ์ฐจ๋‹จ ๊ทผ๋ณธ ํ•ด๊ฒฐ | +| **์ธํ”„๋ผ** | Oracle Cloud ๋‹จ์ผ | **Oracle Cloud + ์ˆ˜ํ‰ ํ™•์žฅ** | ํŠธ๋ž˜ํ”ฝ ์ฆ๊ฐ€ ๋Œ€์‘ | + +--- + +## 5. ๋‹จ๊ณ„๋ณ„ ๊ฐœ๋ฐœ ๋กœ๋“œ๋งต + +### Phase 1 โ€” MVP (2~3๊ฐœ์›”) +> ๋ชฉํ‘œ: ์†Œ์ˆ˜ ๊ณ ๊ฐ์‚ฌ๋ฅผ ๋Œ€์ƒ์œผ๋กœ ํ•ต์‹ฌ ๊ธฐ๋Šฅ ๊ฒ€์ฆ + +- [ ] ๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ DB ์Šคํ‚ค๋งˆ ์„ค๊ณ„ (RLS ํฌํ•จ) +- [ ] ์‚ฌ์šฉ์ž ํšŒ์›๊ฐ€์ž…/๋กœ๊ทธ์ธ (Supabase Auth ํ™œ์šฉ) +- [ ] ๋ฐœ์ „์†Œ ๋“ฑ๋ก UI (์ œ์กฐ์‚ฌ ์„ ํƒ โ†’ ID/PW ์ž…๋ ฅ) +- [ ] ๊ธฐ์กด ํฌ๋กค๋Ÿฌ๋ฅผ ๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ ๊ตฌ์กฐ๋กœ ๋ž˜ํ•‘ +- [ ] ๊ธฐ๋ณธ ๋ชจ๋‹ˆํ„ฐ๋ง ๋Œ€์‹œ๋ณด๋“œ (์ผ/์›”๋ณ„ ๋ฐœ์ „๋Ÿ‰ ์ฐจํŠธ) +- [ ] ์ˆ˜์ง‘ ์‹คํŒจ ์•Œ๋ฆผ ์‹œ์Šคํ…œ + +### Phase 2 โ€” ์•ˆ์ •ํ™” (2~3๊ฐœ์›”) +> ๋ชฉํ‘œ: ๋ณด์•ˆ ๊ฐ•ํ™” ๋ฐ ์šด์˜ ์•ˆ์ •์„ฑ ํ™•๋ณด + +- [ ] AES-256 ๊ณ„์ • ์•”ํ˜ธํ™” ์ฒด๊ณ„ ์ ์šฉ +- [ ] Playwright ํ—ค๋“œ๋ฆฌ์Šค ๋ธŒ๋ผ์šฐ์ € ์ „ํ™˜ (ํƒ์ง€ ์šฐํšŒ) +- [ ] Celery + Redis ๋น„๋™๊ธฐ ํฌ๋กค๋ง ํ ๋„์ž… +- [ ] ๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€ ๊ตฌ์ถ• (์‚ฌ์šฉ์ž/์—ฐ๋™ ์ƒํƒœ ๊ด€๋ฆฌ) +- [ ] ํฌ๋กค๋Ÿฌ ํ—ฌ์Šค์ฒดํฌ ๋ฐ ์ž๋™ ์•Œ๋žŒ ์‹œ์Šคํ…œ + +### Phase 3 โ€” ํ™•์žฅ (3~4๊ฐœ์›”) +> ๋ชฉํ‘œ: ์ƒ์šฉ ์„œ๋น„์Šค ํ’ˆ์งˆ ๋‹ฌ์„ฑ ๋ฐ EMS ๊ธฐ๋ฐ˜ ๋งˆ๋ จ + +- [ ] Residential Proxy Pool ์—ฐ๋™ (IP ์ฐจ๋‹จ ๊ทผ๋ณธ ํ•ด๊ฒฐ) +- [ ] ์‹ ๊ทœ ์ œ์กฐ์‚ฌ ํฌ๋กค๋Ÿฌ ํ”Œ๋Ÿฌ๊ทธ์ธ ์ถ”๊ฐ€ (์ˆ˜์š” ๊ธฐ๋ฐ˜) +- [ ] ์‹œ๊ณ„์—ด DB ๋„์ž… (EMS/VPP ๋Œ€๋น„) +- [ ] ํ™”์ดํŠธ ๋ผ๋ฒจ๋ง ์ง€์› (๋ธŒ๋žœ๋“œ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•) +- [ ] ์ •์‹ ์ œ์กฐ์‚ฌ API ํŒŒํŠธ๋„ˆ์‹ญ ์ถ”์ง„ + +--- + +## 6. ์ข…ํ•ฉ ํ‰๊ฐ€ + +### โœ… ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์˜ ๊ฐ•์  (์žฌํ™œ์šฉ ๊ฐ€๋Šฅ ์ž์‚ฐ) + +1. **๊ฒ€์ฆ๋œ ํฌ๋กค๋Ÿฌ ์ฝ”๋“œ**: NREMS, KREMC, Sun-WMS, Hyundai, CMSolar ๋“ฑ ํ•ต์‹ฌ ์ œ์กฐ์‚ฌ ํฌ๋กค๋Ÿฌ๊ฐ€ ์ด๋ฏธ ์šด์˜ ๊ฒ€์ฆ๋จ. ํ”Œ๋žซํผ ์ „ํ™˜ ์‹œ ๊ฐ€์žฅ ํฐ ๊ธฐ์ˆ ์  ์ž์‚ฐ. +2. **์Šค๋งˆํŠธ ์Šค์ผ€์ค„๋Ÿฌ(crawler_manager)**: LEARNING/OPTIMIZED ๋ชจ๋“œ ์Šค์ผ€์ค„๋ง ๋กœ์ง์€ ๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ ํ™˜๊ฒฝ์—์„œ๋„ ๊ทธ๋Œ€๋กœ ํ™œ์šฉ ๊ฐ€๋Šฅ. +3. **FastAPI + Supabase ๋ฐฑ์—”๋“œ**: ํ™•์žฅ์„ฑ ์žˆ๋Š” ๊ตฌ์กฐ๋กœ ์žฌ์„ค๊ณ„ ๋ถ€๋‹ด ์ตœ์†Œํ™”. + +### โš ๏ธ ํ•ต์‹ฌ ํ•ด๊ฒฐ ๊ณผ์ œ (์šฐ์„ ์ˆœ์œ„ ์ˆœ) + +| ์šฐ์„ ์ˆœ์œ„ | ๊ณผ์ œ | ๋‚œ์ด๋„ | +|---------|------|--------| +| 1 | ์‚ฌ์šฉ์ž ๊ณ„์ •(ID/PW) ์•”ํ˜ธํ™” ๋ณด๊ด€ ์ฒด๊ณ„ | โญโญโญ | +| 2 | IP ์ฐจ๋‹จ ์šฐํšŒ ์ธํ”„๋ผ (Residential Proxy) | โญโญโญโญ | +| 3 | ๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ DB ์„ค๊ณ„ ๋ฐ RLS ์ ์šฉ | โญโญโญ | +| 4 | ํ”„๋ก ํŠธ์—”๋“œ Next.js ์ „ํ™˜ | โญโญ | +| 5 | ๋ฒ•์  ๋ฆฌ์Šคํฌ ๊ฒ€ํ†  (์•ฝ๊ด€ ์œ„๋ฐ˜) | โญโญโญ | + +### ๐Ÿ’ก ์ตœ์ข… ์ œ์–ธ + +> ํ˜„์žฌ SolarPower ํ”„๋กœ์ ํŠธ๋Š” ์ด ํ”Œ๋žซํผ์„ ๊ตฌ์ถ•ํ•˜๊ธฐ ์œ„ํ•œ **ํ›Œ๋ฅญํ•œ ๊ธฐ์ˆ ์  ์›ํ˜•(Prototype)**์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ๋‹จ์ˆœํžˆ "๊ธฐ๋Šฅ ์ถ”๊ฐ€"๊ฐ€ ์•„๋‹ˆ๋ผ **๋ณด์•ˆ, ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ, ์ธํ”„๋ผ** ์„ธ ์ถ•์—์„œ ๊ตฌ์กฐ์ ์ธ ์žฌ์„ค๊ณ„๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. +> +> ํŠนํžˆ **๊ณ„์ • ์ •๋ณด ๋ณด์•ˆ**๊ณผ **IP ์ฐจ๋‹จ ์šฐํšŒ**๋Š” ์„œ๋น„์Šค ์‹ ๋ขฐ๋„์˜ ๊ธฐ๋ฐ˜์ด๋ฏ€๋กœ, ๊ฐœ๋ฐœ ์ฐฉ์ˆ˜ ์ „ ์ด ๋‘ ๊ฐ€์ง€ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๋จผ์ € ํ™•์ •ํ•œ ํ›„ ๋‚˜๋จธ์ง€ ๊ธฐ๋Šฅ์„ ์Œ“๋Š” ์ˆœ์„œ๋กœ ์ง„ํ–‰ํ•˜๊ธฐ๋ฅผ ๊ฐ•๋ ฅํžˆ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค. diff --git a/docs/system_infrastructure_guide.md b/docs/system_infrastructure_guide.md new file mode 100644 index 0000000..1078371 --- /dev/null +++ b/docs/system_infrastructure_guide.md @@ -0,0 +1,75 @@ +# SolarPower ์‹œ์Šคํ…œ ์ธํ”„๋ผ ๋ฐ ๊ตฌ์กฐ ๊ฐ€์ด๋“œ + +์ด ๋ฌธ์„œ๋Š” SolarPower ์‹œ์Šคํ…œ์˜ ์‹ค์ œ ์„œ๋ฒ„ ์ธํ”„๋ผ ์ •๋ณด์™€ ๋กœ์ปฌ/์›๊ฒฉ ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ๋ฅผ ์ •๋ฆฌํ•œ ๊ฐ€์ด๋“œ์ž…๋‹ˆ๋‹ค. + +## 1. ์„œ๋ฒ„ ์ ‘์† ์ •๋ณด (Oracle Cloud) + +ํ˜„์žฌ ์‹œ์Šคํ…œ์˜ ๋ฉ”์ธ ๋กœ์ง(Crawler ๋ฐ API)์ด ๊ตฌ๋™๋˜๊ณ  ์žˆ๋Š” ์„œ๋ฒ„ ์ •๋ณด์ž…๋‹ˆ๋‹ค. + +* **์„œ๋ฒ„ ์ด๋ฆ„:** `holdem-server` +* **๊ณต์ธ IP (Public):** `140.245.73.212` +* **ํ…Œ์ผ์Šค์ผ€์ผ IP:** `100.116.0.32` +* **์ ‘์† ๊ณ„์ •:** `ubuntu` +* **SSH ํ‚ค ๊ฒฝ๋กœ:** `C:\Users\haneu\.ssh\holdem_server.key` + +### ๐Ÿ’ป SSH ์ ‘์† ๋ช…๋ น์–ด +```powershell +# ๊ณต์ธ IP๋กœ ์ ‘์† +ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@140.245.73.212 + +# Tailscale ์—ฐ๊ฒฐ ์‹œ ์ ‘์† +ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@100.116.0.32 +``` + +--- + +## 2. ์„œ๋ฒ„ ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ (Remote) + +์„œ๋ฒ„ ๋‚ด ์ฃผ์š” ์„œ๋น„์Šค ์„ค์น˜ ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค. + +* **`~/solorpower_crawler/`**: ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘๊ธฐ(Crawler)๊ฐ€ ์‹คํ–‰๋˜๋Š” ๋ฉ”์ธ ๊ฒฝ๋กœ + * `main.py`: 10๋ถ„๋งˆ๋‹ค ์‹คํ–‰๋˜๋Š” ์ˆ˜์ง‘ ์—”ํŠธ๋ฆฌ + * `crawler.log`: ํฌ๋กค๋ง ์‹คํ–‰ ๋กœ๊ทธ (ํ‘œ์ค€ ์ถœ๋ ฅ/์—๋Ÿฌ ํ†ตํ•ฉ) + * `database.py`: Supabase ์ €์žฅ ๋กœ์ง (์ตœ๊ทผ 0kW ๋ณดํ˜ธ ํŒจ์น˜ ์ ์šฉ๋จ) +* **`~/solorpower_server/`**: FastAPI ๊ธฐ๋ฐ˜์˜ ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„ ๊ฒฝ๋กœ +* **`~/plant_sync/`**: ๋ฐœ์ „์†Œ ์ •๋ณด ๋™๊ธฐํ™” ๋ฐ ๊ธฐํƒ€ ์œ ํ‹ธ๋ฆฌํ‹ฐ + +### โฐ ์ž๋™ํ™” ์Šค์ผ€์ค„ (Crontab) +์„œ๋ฒ„์—์„œ `crontab -l` ๋ช…๋ น์–ด๋กœ ํ™•์ธ๋œ ์ž๋™ํ™” ์„ค์ •์ž…๋‹ˆ๋‹ค. +* `*/10 * * * *`: 10๋ถ„๋งˆ๋‹ค ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์‹คํ–‰ (`main.py`) +* `10 0 * * *`: ๋งค์ผ 0์‹œ 10๋ถ„์— ์ผ์ผ ํ†ต๊ณ„ ์š”์•ฝ ์‹คํ–‰ (`daily_summary.py`) + +--- + +## 3. ๋กœ์ปฌ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ (Local) + +๊ฐœ๋ฐœ PC(`d:\dev\etc\SolorPower`)์˜ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค. + +* **`crawler/`**: Python ๊ธฐ๋ฐ˜ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘๊ธฐ ์†Œ์Šค ์ฝ”๋“œ +* **`api_server/`**: FastAPI ๊ธฐ๋ฐ˜ ๋ฐฑ์—”๋“œ ์†Œ์Šค ์ฝ”๋“œ +* **`app/`**: React Native (Expo) ๋ชจ๋ฐ”์ผ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์†Œ์Šค ์ฝ”๋“œ +* **`sql/`**: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ ๋ฐ ํžˆ์Šคํ† ๋ฆฌ ๊ด€๋ฆฌ +* **`docs/`**: ์‹œ์Šคํ…œ ์„ค๊ณ„ ๋ฐ ๋ถ„์„ ๋ฆฌํฌํŠธ (ํ˜„์žฌ ์ด ๋ฌธ์„œ๊ฐ€ ํฌํ•จ๋œ ์œ„์น˜) + +--- + +## 4. ๋ฐ์ดํ„ฐ ํ๋ฆ„ (Data Flow) + +1. **์ˆ˜์ง‘:** Crawler(Oracle Cloud) โ†’ ๊ฐ ๋ฐœ์ „์†Œ ์‚ฌ์ดํŠธ (๋‚˜์Šค ํ”„๋ก์‹œ ๊ฒฝ์œ ) +2. **์ €์žฅ:** Crawler โ†’ Supabase (PostgreSQL) +3. **์•Œ๋ฆผ:** Crawler โ†’ Telegram Bot (์ด์ƒ ๊ฐ์ง€ ์‹œ ์ฆ‰์‹œ ๋ฐœ์†ก) +4. **์ œ๊ณต:** API Server(Oracle Cloud) โ†’ Supabase ๋ฐ์ดํ„ฐ ๊ฐ€๊ณต ํ›„ JSON ์ œ๊ณต +5. **ํ‘œ์‹œ:** Mobile App (Expo) โ†’ API ํ˜ธ์ถœ ํ›„ ์ฐจํŠธ ๋ฐ ํ˜„ํ™ฉ ํ‘œ์‹œ + +--- + +## 5. ์ฃผ์š” ์ ๊ฒ€ ๋ฐ ๋Œ€์‘ ๊ฐ€์ด๋“œ + +### ๋ฐœ์ „๋Ÿ‰์ด 0์œผ๋กœ ํ‘œ์‹œ๋  ๋•Œ (์˜ค๋Š˜ ๋ฐœ์ƒ ์‚ฌ๋ก€) +1. **์„œ๋ฒ„ ์ ‘์†:** `ssh oracle-fishingmap`์ด ์•„๋‹Œ `140.245.73.212`๋กœ ์ ‘์† ํ™•์ธ +2. **๋กœ๊ทธ ํ™•์ธ:** `tail -n 20 ~/solorpower_crawler/crawler.log` +3. **์ฝ”๋“œ ํŒจ์น˜:** `git pull origin main` (์ตœ์‹  ๋ณดํ˜ธ ๋กœ์ง ๋ฐ˜์˜ ์—ฌ๋ถ€ ํ™•์ธ) +4. **DB ํ™•์ธ:** Supabase `solar_logs` ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹น ์‹œ๊ฐ„๋Œ€ `status` ํ™•์ธ + +--- +*๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ: 2026-05-14 (0kW ์˜ค๋ณด ๋ณดํ˜ธ ํŒจ์น˜ ๋ฐ ์ธํ”„๋ผ ์ •๋ฆฌ)* diff --git a/sql/00_init_schema.sql b/sql/00_init_schema.sql new file mode 100644 index 0000000..a0f9d6d --- /dev/null +++ b/sql/00_init_schema.sql @@ -0,0 +1,80 @@ +-- ============================================================================== +-- Initial Schema Migration Dump +-- Generated: 2026-04-14 +-- Description: ํ˜„์žฌ Supabase DB์— ์กด์žฌํ•˜๋Š” ์ „์ฒด ํ…Œ์ด๋ธ” ๊ตฌ์กฐ ์š”์•ฝ (์˜์กด์„ฑ ์ˆœ์„œ) +-- ============================================================================== + +-- ------------------------------------------------------------------------------ +-- 1. COMPANIES (์—…์ฒด ๋ฐ ๊ณ ๊ฐ์‚ฌ ์ •๋ณด) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.companies ( + id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- ------------------------------------------------------------------------------ +-- 2. PLANTS (๋ฐœ์ „์†Œ ๊ธฐ๋ณธ ์ •๋ณด) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.plants ( + id TEXT PRIMARY KEY, + company_id BIGINT REFERENCES public.companies(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT, + capacity DOUBLE PRECISION DEFAULT 100.0, + initial_cumulative_kwh DOUBLE PRECISION DEFAULT 0.0, + constructed_at DATE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON COLUMN public.plants.initial_cumulative_kwh IS '์‹œ์Šคํ…œ ๋„์ž… ์ด์ „์˜ ๋ˆ„์  ๋ฐœ์ „๋Ÿ‰ (๊ธฐ์ดˆ ์ž์‚ฐ)'; + +-- ------------------------------------------------------------------------------ +-- 3. SOLAR_LOGS (์‹ค์‹œ๊ฐ„/์‹œ๊ฐ„ ๋‹จ์œ„ ๋ฐœ์ „ ๋กœ๊ทธ) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.solar_logs ( + id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + plant_id TEXT REFERENCES public.plants(id) ON DELETE CASCADE, + current_kw DOUBLE PRECISION, + today_kwh DOUBLE PRECISION, + status TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- ------------------------------------------------------------------------------ +-- 4. DAILY_STATS (์ผ์ผ ๋ฐœ์ „ ํ†ต๊ณ„ ์š”์•ฝ) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.daily_stats ( + id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + plant_id TEXT REFERENCES public.plants(id) ON DELETE CASCADE, + date DATE NOT NULL, + total_generation DOUBLE PRECISION DEFAULT 0.0, + peak_kw DOUBLE PRECISION DEFAULT 0.0, + generation_hours DOUBLE PRECISION DEFAULT 0.0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(plant_id, date) -- ํฌ๋กค๋Ÿฌ์—์„œ UPSERT์‹œ ์‚ฌ์šฉ๋˜๋Š” ์ œ์•ฝ์กฐ๊ฑด +); + +COMMENT ON TABLE public.daily_stats IS '์ผ์ผ ๋ฐœ์ „ ํ†ต๊ณ„ ์š”์•ฝ ํ…Œ์ด๋ธ”'; +COMMENT ON COLUMN public.daily_stats.total_generation IS '๋‹น์ผ ์ด ๋ฐœ์ „๋Ÿ‰ (kWh)'; +COMMENT ON COLUMN public.daily_stats.peak_kw IS '๋‹น์ผ ์ตœ๊ณ  ์ถœ๋ ฅ (kW)'; +COMMENT ON COLUMN public.daily_stats.generation_hours IS '๋ฐœ์ „ ์‹œ๊ฐ„ = ๋ฐœ์ „๋Ÿ‰ / ์„ค๋น„์šฉ๋Ÿ‰'; + +CREATE INDEX IF NOT EXISTS idx_daily_stats_plant_id ON public.daily_stats(plant_id); +CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON public.daily_stats(date); +CREATE INDEX IF NOT EXISTS idx_daily_stats_plant_date ON public.daily_stats(plant_id, date); + +-- ------------------------------------------------------------------------------ +-- 5. MONTHLY_STATS (์›”๊ฐ„ ๋ฐœ์ „ ํ†ต๊ณ„ ์š”์•ฝ) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.monthly_stats ( + id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + plant_id TEXT REFERENCES public.plants(id) ON DELETE CASCADE, + month TEXT NOT NULL, + total_generation DOUBLE PRECISION DEFAULT 0.0, + currnet_last_date TEXT, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()), + + UNIQUE(plant_id, month) -- ํฌ๋กค๋Ÿฌ์—์„œ UPSERT์‹œ ์‚ฌ์šฉ๋˜๋Š” ์ œ์•ฝ์กฐ๊ฑด +); diff --git a/sql/DATABASE_HISTORY_AND_SECURITY.md b/sql/DATABASE_HISTORY_AND_SECURITY.md new file mode 100644 index 0000000..2a50a91 --- /dev/null +++ b/sql/DATABASE_HISTORY_AND_SECURITY.md @@ -0,0 +1,42 @@ +# ๐Ÿ—„๏ธ SolarPower Database History & Security Requirements + +์ด ๋ฌธ์„œ๋Š” ๊ณผ๊ฑฐ์˜ ํŒŒํŽธํ™”๋œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—…๋ฐ์ดํŠธ ๋‚ด์—ญ๊ณผ ๋ณด์•ˆ(RLS) ์ ๊ฒ€ ๋ณด๊ณ ์„œ๋ฅผ ํ•˜๋‚˜๋กœ ํ†ตํ•ฉํ•œ ๊ฐ€์ด๋“œ์ž…๋‹ˆ๋‹ค. **Supabase ๊ด€๋ฆฌ ์‹œ ์ด ๋ฌธ์„œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋ณด์•ˆ ์ •์ฑ…๊ณผ ๊ณผ๊ฑฐ ๋ณ€๊ฒฝ ์ด๋ ฅ์„ ์ฐธ๊ณ ํ•˜์„ธ์š”.** + +## 1. ๐Ÿ“‹ ํ…Œ์ด๋ธ” ๊ตฌ์กฐ ํ•ต์‹ฌ ๋ณ€๊ฒฝ ์ด๋ ฅ +| ์ผ์ž | ๋‚ด์šฉ | ๋น„๊ณ  | +|---|---|---| +| **2026-01-23** | `daily_stats` (์ผ๋ณ„ ํ†ต๊ณ„ ์š”์•ฝ ํ…Œ์ด๋ธ”) ์‹ ์„ค ๋ฐ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ | `plant_id`, `date` ๋ณตํ•ฉ ์ธ๋ฑ์Šค ๋ฐ Unique ์ œ์•ฝ์กฐ๊ฑด ์ ์šฉ | +| **2026-01-23** | `plants` ํ…Œ์ด๋ธ” `initial_cumulative_kwh` ์ปฌ๋Ÿผ ์ถ”๊ฐ€ | ์‹œ์Šคํ…œ ๋„์ž… ์ด์ „์˜ ๋ˆ„์  ๋ฐœ์ „๋Ÿ‰(์ž์‚ฐ) ์ถ”์  ๋ชฉ์  | +| **2026-01-30** | RLS ๋ณด์•ˆ ์ •์ฑ… ๋Œ€๊ทœ๋ชจ ์ ์šฉ | `plants`, `solar_logs`, `daily_stats`, `monthly_stats` ํ…Œ์ด๋ธ” ์ „์ฒด | +| **2026-04-14** | `plants` ํ…Œ์ด๋ธ” `alerts_enabled` ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (์ง„ํ–‰) | ๋Œ€์‹œ๋ณด๋“œ์—์„œ ์•ฑ ์•Œ๋ฆผ์„ ํ† ๊ธ€๋งํ•  ์ˆ˜ ์žˆ๋„๋ก ๋ถˆ๋ฆฌ์–ธ ์ œ์–ด ์ปฌ๋Ÿผ ์ถ”๊ฐ€ | + +--- + +## 2. ๐Ÿ›ก๏ธ ๋ณด์•ˆ ํ†ต์ œ ์ •์ฑ… (Row Level Security) + +๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ์ฃผ์š” 4๊ฐœ ํ…Œ์ด๋ธ”(`plants`, `solar_logs`, `daily_stats`, `monthly_stats`)์—๋Š” ์น˜๋ช…์ ์ธ ๋ฌด๋‹จ ์กฐ์ž‘์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ์—„๊ฒฉํ•œ **RLS(Row Level Security)** ๊ฐ€ ์ ์šฉ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. + +### ์‚ฌ์šฉ์ž ๋“ฑ๊ธ‰๋ณ„ ๊ถŒํ•œ +- **API ์„œ๋ฒ„ ๋ฐ ํฌ๋กค๋Ÿฌ (service_role)**: ๋ชจ๋“  ๋ฐ์ดํ„ฐ์— ๋Œ€ํ•œ ์ž์œ ๋กœ์šด ์ฝ๊ธฐ/์“ฐ๊ธฐ(INSERT, UPDATE) ๊ถŒํ•œ ๋ณด์œ (`.env` ํŒŒ์ผ ๋‚ด `SUPABASE_KEY`๊ฐ€ `service_role` ํ† ํฐ์ด์–ด์•ผ ํ•จ). +- **์ผ๋ฐ˜ ์ต๋ช… ์‚ฌ์šฉ์ž (anon ๊ถŒํ•œ - App/Dashboard)**: ์กฐํšŒ(SELECT)๋งŒ ๊ฐ€๋Šฅํ•˜๋ฉฐ ๋ฐ์ดํ„ฐ ์ˆ˜์ •์ด๋‚˜ ์‹๋ณ„ ๋ถˆ๊ฐ€๋Šฅํ•œ ๋ฐ์ดํ„ฐ ์กฐ์ž‘์€ ์›์ฒœ ์ฐจ๋‹จ๋ฉ๋‹ˆ๋‹ค. + +```sql +-- RLS ํ™œ์„ฑํ™” ์˜ˆ์‹œ +ALTER TABLE plants ENABLE ROW LEVEL SECURITY; + +-- ์ต๋ช… ์‚ฌ์šฉ์ž๋Š” ์กฐํšŒ๋งŒ ๊ฐ€๋Šฅ +CREATE POLICY "Allow anonymous read access" ON plants FOR SELECT TO anon USING (true); + +-- ์„œ๋น„์Šค ๋กœ์ง์€ ๋ชจ๋“  ๊ถŒํ•œ ํ—ˆ์šฉ +CREATE POLICY "Allow service_role full access" ON plants FOR ALL TO service_role USING (true) WITH CHECK (true); +``` + +--- + +## 3. ๐Ÿšจ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์šด์˜ ํ™˜๊ฒฝ ๊ถŒ์žฅ ์‚ฌํ•ญ + +1. **API ํ‚ค ๊ด€๋ฆฌ ์ฃผ์˜์ **: + - ํ”„๋ก ํŠธ์—”๋“œ ๋ชจ๋ฐ”์ผ ์•ฑ/๋Œ€์‹œ๋ณด๋“œ ์†Œ์Šค์ฝ”๋“œ์— ์‚ฝ์ž…๋˜๋Š” Supabase Key๋Š” ๋ฐ˜๋“œ์‹œ `anon` (์ฝ๊ธฐ ์ „์šฉ ํผ๋ธ”๋ฆญ) ํ‚ค์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค. + - ํฌ๋กค๋Ÿฌ(`.env`)์™€ ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„(`.env`)๋Š” `service_role` (๋งˆ์Šคํ„ฐ) ํ‚ค๋ฅผ ์‚ฌ์šฉ ์ค‘์ž…๋‹ˆ๋‹ค. ์ด ํ‚ค๊ฐ€ ์™ธ๋ถ€ ์ €์žฅ์†Œ๋‚˜ ํด๋ผ์ด์–ธํŠธ์— ๋…ธ์ถœ๋˜์ง€ ์•Š๋„๋ก ๊ฐ๋ณ„ํžˆ ์œ ์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. +2. **IP ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ ๊ฒ€ํ† **: + - ์˜ค๋ผํด ํด๋ผ์šฐ๋“œ(API ์„œ๋ฒ„) ๋ฐ NAS ํฌ๋กค๋Ÿฌ ์„œ๋ฒ„์—์„œ๋งŒ Supabase DB์— ์ง์ ‘ Write ํ•  ์ˆ˜ ์žˆ๋„๋ก DB Network Restrictions ์„ค์ •์—์„œ IP ๊ธฐ๋ฐ˜ ์ œํ•œ์„ ๊ฑธ์–ด๋‘๋Š” ๊ฒƒ์„ ๊ฐ•๋ ฅํžˆ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค. diff --git a/sql/migration_add_alerts_enabled.sql b/sql/migration_add_alerts_enabled.sql new file mode 100644 index 0000000..036d892 --- /dev/null +++ b/sql/migration_add_alerts_enabled.sql @@ -0,0 +1,10 @@ +-- Migration: Add alerts_enabled to plants table +-- Description: ๋ฐœ์ „์†Œ ๋Œ€์‹œ๋ณด๋“œ(๋ชจ๋ฐ”์ผ/์›น)์—์„œ ์ด์ƒ ์•Œ๋ฆผ์„ ๋„๊ฑฐ๋‚˜ ์ผค ์ˆ˜ ์žˆ๋„๋ก ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜๋Š” ์ปฌ๋Ÿผ ์ถ”๊ฐ€ +-- Created At: 2026-04-14 + +-- 1. plants ํ…Œ์ด๋ธ”์— alerts_enabled ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (๊ธฐ๋ณธ๊ฐ’: true) +ALTER TABLE public.plants +ADD COLUMN IF NOT EXISTS alerts_enabled BOOLEAN DEFAULT TRUE; + +-- 2. ์ปฌ๋Ÿผ ์—ญํ• ์— ๋Œ€ํ•œ ์„ค๋ช…(์ฃผ์„) ์ถ”๊ฐ€ +COMMENT ON COLUMN public.plants.alerts_enabled IS '๋ฐœ์ „์†Œ ์ด์ƒ ๊ฐ์ง€ ์•Œ๋ฆผ ํ™œ์„ฑํ™” ์—ฌ๋ถ€ (true: ์•Œ๋ฆผ ์ผฌ, false: ์•Œ๋ฆผ ๋”)'; diff --git a/tools/mapping.json b/tools/mapping.json new file mode 100644 index 0000000..ee99375 --- /dev/null +++ b/tools/mapping.json @@ -0,0 +1,12 @@ +{ + "1ํ˜ธ๊ธฐ": "nrems-01", + "1ํ˜ธ๊ธฐ.1": "nrems-01", + "2ํ˜ธ๊ธฐ": "nrems-02", + "3ํ˜ธ๊ธฐ": "nrems-03", + "4ํ˜ธ๊ธฐ": "nrems-04", + "5ํ˜ธ๊ธฐ": "nrems-05", + "6ํ˜ธ๊ธฐ": "nrems-06", + "8ํ˜ธ๊ธฐ": "nrems-08", + "9ํ˜ธ๊ธฐ": "nrems-09", + "10ํ˜ธ๊ธฐ": "nrems-10" +} \ No newline at end of file diff --git a/tools/solar_converter_gui.py b/tools/solar_converter_gui.py new file mode 100644 index 0000000..91004c4 --- /dev/null +++ b/tools/solar_converter_gui.py @@ -0,0 +1,406 @@ +""" +solar_converter_gui.py - ํƒœ์–‘๊ด‘ ๋ฐœ์ „ ์—‘์…€ ๋ฐ์ดํ„ฐ ์ „์ฒ˜๋ฆฌ GUI +============================================================ +์—‘์…€ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ์–ด DB ์—…๋กœ๋“œ์šฉ ํ‘œ์ค€ ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. +""" + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import json +import os +from pathlib import Path + +try: + import pandas as pd +except ImportError: + print("pandas ์„ค์น˜ ํ•„์š”: pip install pandas openpyxl") + exit(1) + + +class SolarConverterApp: + """ํƒœ์–‘๊ด‘ ๋ฐœ์ „ ์—‘์…€ ๋ณ€ํ™˜ GUI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜""" + + CONFIG_FILE = "mapping.json" + + def __init__(self, root): + self.root = root + self.root.title("โ˜€๏ธ ํƒœ์–‘๊ด‘ ๋ฐœ์ „ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜๊ธฐ") + self.root.geometry("900x700") + self.root.minsize(800, 600) + + # ์ƒํƒœ ๋ณ€์ˆ˜ + self.file_path = tk.StringVar(value="ํŒŒ์ผ์„ ์„ ํƒํ•˜์„ธ์š”") + self.skip_rows = tk.IntVar(value=0) + self.df = None # ์›๋ณธ ๋ฐ์ดํ„ฐํ”„๋ ˆ์ž„ + self.columns = [] # ์ปฌ๋Ÿผ ๋ฆฌ์ŠคํŠธ + self.mapping_widgets = {} # ์ปฌ๋Ÿผ๋ณ„ ๋งคํ•‘ ์œ„์ ฏ + + self._create_ui() + self._load_mapping() + + def _create_ui(self): + """UI ์ƒ์„ฑ""" + # ๋ฉ”์ธ ํ”„๋ ˆ์ž„ + main_frame = ttk.Frame(self.root, padding=10) + main_frame.pack(fill=tk.BOTH, expand=True) + + # === 1. ํŒŒ์ผ ์„ ํƒ ์˜์—ญ === + file_frame = ttk.LabelFrame(main_frame, text="๐Ÿ“ ํŒŒ์ผ ์„ ํƒ", padding=10) + file_frame.pack(fill=tk.X, pady=(0, 10)) + + ttk.Button(file_frame, text="์›๋ณธ ์—‘์…€ ์—ด๊ธฐ", command=self._open_file).pack(side=tk.LEFT) + ttk.Label(file_frame, textvariable=self.file_path, foreground="blue").pack(side=tk.LEFT, padx=10) + + # === 2. ๊ตฌ์กฐ ๋ถ„์„ ์˜์—ญ === + analysis_frame = ttk.LabelFrame(main_frame, text="๐Ÿ” ๊ตฌ์กฐ ๋ถ„์„", padding=10) + analysis_frame.pack(fill=tk.X, pady=(0, 10)) + + row1 = ttk.Frame(analysis_frame) + row1.pack(fill=tk.X) + + ttk.Label(row1, text="๊ฑด๋„ˆ๋›ธ ํ–‰(Skip Rows):").pack(side=tk.LEFT) + ttk.Spinbox(row1, from_=0, to=100, width=5, textvariable=self.skip_rows).pack(side=tk.LEFT, padx=5) + ttk.Button(row1, text="๐Ÿ“‹ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", command=self._preview_data).pack(side=tk.LEFT, padx=10) + + # ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ…Œ์ด๋ธ” + preview_container = ttk.Frame(analysis_frame) + preview_container.pack(fill=tk.BOTH, expand=True, pady=(10, 0)) + + self.preview_tree = ttk.Treeview(preview_container, height=5, show="headings") + preview_scroll_x = ttk.Scrollbar(preview_container, orient=tk.HORIZONTAL, command=self.preview_tree.xview) + preview_scroll_y = ttk.Scrollbar(preview_container, orient=tk.VERTICAL, command=self.preview_tree.yview) + self.preview_tree.configure(xscrollcommand=preview_scroll_x.set, yscrollcommand=preview_scroll_y.set) + + self.preview_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + preview_scroll_y.pack(side=tk.RIGHT, fill=tk.Y) + preview_scroll_x.pack(side=tk.BOTTOM, fill=tk.X) + + # === 3. ๋ฐœ์ „์†Œ ๋งคํ•‘ ์˜์—ญ === + mapping_outer = ttk.LabelFrame(main_frame, text="๐Ÿ”— ๋ฐœ์ „์†Œ ๋งคํ•‘", padding=10) + mapping_outer.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + # ๋งคํ•‘ ๋ฒ„ํŠผ ํ–‰ + mapping_buttons = ttk.Frame(mapping_outer) + mapping_buttons.pack(fill=tk.X, pady=(0, 10)) + + ttk.Button(mapping_buttons, text="๐Ÿ’พ ๋งคํ•‘ ์ €์žฅ", command=self._save_mapping).pack(side=tk.LEFT, padx=2) + ttk.Button(mapping_buttons, text="๐Ÿ“‚ ๋งคํ•‘ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", command=self._load_mapping).pack(side=tk.LEFT, padx=2) + ttk.Button(mapping_buttons, text="๐Ÿ—‘๏ธ ๋งคํ•‘ ์ดˆ๊ธฐํ™”", command=self._clear_mapping).pack(side=tk.LEFT, padx=2) + ttk.Label(mapping_buttons, text=" | ๋‚ ์งœ ์ปฌ๋Ÿผ:").pack(side=tk.LEFT, padx=(20, 5)) + self.date_column_var = tk.StringVar() + self.date_column_combo = ttk.Combobox(mapping_buttons, textvariable=self.date_column_var, width=15, state="readonly") + self.date_column_combo.pack(side=tk.LEFT) + + # ๋งคํ•‘ ์Šคํฌ๋กค ์˜์—ญ + mapping_canvas = tk.Canvas(mapping_outer) + mapping_scrollbar = ttk.Scrollbar(mapping_outer, orient=tk.VERTICAL, command=mapping_canvas.yview) + self.mapping_frame = ttk.Frame(mapping_canvas) + + mapping_canvas.configure(yscrollcommand=mapping_scrollbar.set) + mapping_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + mapping_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + canvas_window = mapping_canvas.create_window((0, 0), window=self.mapping_frame, anchor="nw") + + def on_frame_configure(e): + mapping_canvas.configure(scrollregion=mapping_canvas.bbox("all")) + + def on_canvas_configure(e): + mapping_canvas.itemconfig(canvas_window, width=e.width) + + self.mapping_frame.bind("", on_frame_configure) + mapping_canvas.bind("", on_canvas_configure) + + # ๋งˆ์šฐ์Šค ํœ  ์Šคํฌ๋กค + def on_mousewheel(e): + mapping_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units") + mapping_canvas.bind_all("", on_mousewheel) + + # === 4. ๋ณ€ํ™˜ ๋ฐ ์ €์žฅ ์˜์—ญ === + action_frame = ttk.Frame(main_frame) + action_frame.pack(fill=tk.X) + + ttk.Button( + action_frame, + text="โฌ‡๏ธ ๋ณ€ํ™˜ํ•˜์—ฌ ์ €์žฅํ•˜๊ธฐ", + command=self._convert_and_save, + style="Accent.TButton" + ).pack(side=tk.RIGHT) + + self.status_label = ttk.Label(action_frame, text="์ค€๋น„๋จ", foreground="gray") + self.status_label.pack(side=tk.LEFT) + + # ์Šคํƒ€์ผ ์„ค์ • + style = ttk.Style() + style.configure("Accent.TButton", font=("", 12, "bold")) + + def _open_file(self): + """ํŒŒ์ผ ์—ด๊ธฐ ๋Œ€ํ™”์ƒ์ž""" + path = filedialog.askopenfilename( + title="์—‘์…€ ํŒŒ์ผ ์„ ํƒ", + filetypes=[ + ("Excel Files", "*.xlsx *.xls"), + ("All Files", "*.*") + ] + ) + if path: + self.file_path.set(path) + self._status("ํŒŒ์ผ ์„ ํƒ๋จ. '๋ฏธ๋ฆฌ๋ณด๊ธฐ' ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์„ธ์š”.") + + def _preview_data(self): + """๋ฐ์ดํ„ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ""" + path = self.file_path.get() + if not os.path.exists(path): + messagebox.showerror("์˜ค๋ฅ˜", "๋จผ์ € ์—‘์…€ ํŒŒ์ผ์„ ์„ ํƒํ•˜์„ธ์š”.") + return + + try: + skip = self.skip_rows.get() + self.df = pd.read_excel(path, skiprows=skip) + self.columns = list(self.df.columns) + + # ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ…Œ์ด๋ธ” ์—…๋ฐ์ดํŠธ + self.preview_tree.delete(*self.preview_tree.get_children()) + self.preview_tree["columns"] = self.columns + + for col in self.columns: + self.preview_tree.heading(col, text=col) + self.preview_tree.column(col, width=100, minwidth=50) + + # ์ƒ์œ„ 5ํ–‰ ํ‘œ์‹œ + for idx, row in self.df.head(5).iterrows(): + values = [str(v) if pd.notna(v) else "" for v in row] + self.preview_tree.insert("", tk.END, values=values) + + # ๋งคํ•‘ UI ์ƒ์„ฑ + self._create_mapping_ui() + self._status(f"๋ฏธ๋ฆฌ๋ณด๊ธฐ ์™„๋ฃŒ: {len(self.df)}ํ–‰, {len(self.columns)}์—ด") + + except Exception as e: + messagebox.showerror("์˜ค๋ฅ˜", f"ํŒŒ์ผ ์ฝ๊ธฐ ์‹คํŒจ:\n{e}") + + def _create_mapping_ui(self): + """์ปฌ๋Ÿผ ๋งคํ•‘ UI ์ƒ์„ฑ""" + # ๊ธฐ์กด ์œ„์ ฏ ์ œ๊ฑฐ + for widget in self.mapping_frame.winfo_children(): + widget.destroy() + self.mapping_widgets = {} + + # ๋‚ ์งœ ์ปฌ๋Ÿผ ์ฝค๋ณด๋ฐ•์Šค ์—…๋ฐ์ดํŠธ + self.date_column_combo["values"] = self.columns + if self.columns: + # ๋‚ ์งœ ๊ด€๋ จ ์ปฌ๋Ÿผ ์ž๋™ ์„ ํƒ + for col in self.columns: + if any(kw in str(col).lower() for kw in ['date', '๋‚ ์งœ', '์ผ์ž', 'day']): + self.date_column_var.set(col) + break + else: + self.date_column_var.set(self.columns[0]) + + # ํ—ค๋” + header = ttk.Frame(self.mapping_frame) + header.pack(fill=tk.X, pady=(0, 5)) + ttk.Label(header, text="ํฌํ•จ", width=5).pack(side=tk.LEFT) + ttk.Label(header, text="์—‘์…€ ์ปฌ๋Ÿผ๋ช…", width=25, anchor="w").pack(side=tk.LEFT, padx=5) + ttk.Label(header, text="โ†’", width=3).pack(side=tk.LEFT) + ttk.Label(header, text="DB Plant ID", width=20, anchor="w").pack(side=tk.LEFT, padx=5) + + ttk.Separator(self.mapping_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5) + + # ๊ฐ ์ปฌ๋Ÿผ์— ๋Œ€ํ•œ ๋งคํ•‘ ํ–‰ + for col in self.columns: + row = ttk.Frame(self.mapping_frame) + row.pack(fill=tk.X, pady=2) + + # ํฌํ•จ ์ฒดํฌ๋ฐ•์Šค + include_var = tk.BooleanVar(value=True) + chk = ttk.Checkbutton(row, variable=include_var, width=3) + chk.pack(side=tk.LEFT) + + # ์ปฌ๋Ÿผ๋ช… + ttk.Label(row, text=str(col), width=25, anchor="w").pack(side=tk.LEFT, padx=5) + + # ํ™”์‚ดํ‘œ + ttk.Label(row, text="โ†’", width=3).pack(side=tk.LEFT) + + # DB ID ์ž…๋ ฅ + db_id_var = tk.StringVar() + entry = ttk.Entry(row, textvariable=db_id_var, width=20) + entry.pack(side=tk.LEFT, padx=5) + + # ์ž๋™ ๋งคํ•‘ ์ถ”์ฒœ + suggested_id = self._suggest_plant_id(col) + if suggested_id: + db_id_var.set(suggested_id) + + # ์œ„์ ฏ ์ €์žฅ + self.mapping_widgets[col] = { + 'include': include_var, + 'db_id': db_id_var + } + + def _suggest_plant_id(self, column_name): + """์ปฌ๋Ÿผ๋ช…์—์„œ ๋ฐœ์ „์†Œ ID ์ถ”์ฒœ""" + col_str = str(column_name).lower() + + # ํ˜ธ๊ธฐ ๋ฒˆํ˜ธ ์ถ”์ถœ + import re + match = re.search(r'(\d+)\s*ํ˜ธ๊ธฐ?', col_str) + if match: + num = match.group(1).zfill(2) + return f"nrems-{num}" + + # ๋‚ ์งœ ๊ด€๋ จ ์ปฌ๋Ÿผ์€ ๋น„์›Œ๋‘  + if any(kw in col_str for kw in ['date', '๋‚ ์งœ', '์ผ์ž', 'day', '์›”', 'month']): + return "" + + return "" + + def _save_mapping(self): + """๋งคํ•‘ ์„ค์ • ์ €์žฅ""" + mapping = {} + for col, widgets in self.mapping_widgets.items(): + mapping[str(col)] = { + 'include': widgets['include'].get(), + 'db_id': widgets['db_id'].get() + } + + mapping['_date_column'] = self.date_column_var.get() + mapping['_skip_rows'] = self.skip_rows.get() + + try: + with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(mapping, f, ensure_ascii=False, indent=2) + self._status(f"๋งคํ•‘ ์ €์žฅ๋จ: {self.CONFIG_FILE}") + except Exception as e: + messagebox.showerror("์˜ค๋ฅ˜", f"์ €์žฅ ์‹คํŒจ: {e}") + + def _load_mapping(self): + """์ €์žฅ๋œ ๋งคํ•‘ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ""" + if not os.path.exists(self.CONFIG_FILE): + return + + try: + with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f: + mapping = json.load(f) + + # skip_rows ๋ณต์› + if '_skip_rows' in mapping: + self.skip_rows.set(mapping['_skip_rows']) + + # ๋‚ ์งœ ์ปฌ๋Ÿผ ๋ณต์› + if '_date_column' in mapping: + self.date_column_var.set(mapping['_date_column']) + + # ๋งคํ•‘ ์œ„์ ฏ ์—…๋ฐ์ดํŠธ + for col, widgets in self.mapping_widgets.items(): + col_str = str(col) + if col_str in mapping: + widgets['include'].set(mapping[col_str].get('include', True)) + widgets['db_id'].set(mapping[col_str].get('db_id', '')) + + self._status("๋งคํ•‘ ๋ถˆ๋Ÿฌ์˜ด") + except Exception as e: + print(f"๋งคํ•‘ ๋กœ๋“œ ์˜ค๋ฅ˜: {e}") + + def _clear_mapping(self): + """๋งคํ•‘ ์ดˆ๊ธฐํ™”""" + for widgets in self.mapping_widgets.values(): + widgets['include'].set(True) + widgets['db_id'].set("") + self._status("๋งคํ•‘ ์ดˆ๊ธฐํ™”๋จ") + + def _convert_and_save(self): + """๋ณ€ํ™˜ ๋ฐ ์ €์žฅ""" + if self.df is None or self.df.empty: + messagebox.showerror("์˜ค๋ฅ˜", "๋จผ์ € ๋ฐ์ดํ„ฐ๋ฅผ ๋ฏธ๋ฆฌ๋ณด๊ธฐํ•˜์„ธ์š”.") + return + + date_col = self.date_column_var.get() + if not date_col: + messagebox.showerror("์˜ค๋ฅ˜", "๋‚ ์งœ ์ปฌ๋Ÿผ์„ ์„ ํƒํ•˜์„ธ์š”.") + return + + # ๋งคํ•‘๋œ ์ปฌ๋Ÿผ ์ถ”์ถœ + mapped_columns = {} + for col, widgets in self.mapping_widgets.items(): + if widgets['include'].get() and widgets['db_id'].get(): + mapped_columns[col] = widgets['db_id'].get() + + if not mapped_columns: + messagebox.showerror("์˜ค๋ฅ˜", "์ตœ์†Œ ํ•˜๋‚˜์˜ ์ปฌ๋Ÿผ์„ ๋งคํ•‘ํ•˜์„ธ์š”.") + return + + try: + # ํ•„์š”ํ•œ ์ปฌ๋Ÿผ๋งŒ ์ถ”์ถœ + cols_to_use = [date_col] + list(mapped_columns.keys()) + df_subset = self.df[cols_to_use].copy() + + # melt๋กœ ๋ณ€ํ™˜ (wide โ†’ long) + df_melted = df_subset.melt( + id_vars=[date_col], + var_name='excel_column', + value_name='generation' + ) + + # plant_id ๋งคํ•‘ + df_melted['plant_id'] = df_melted['excel_column'].map(mapped_columns) + + # ๋‚ ์งœ ํ‘œ์ค€ํ™” + df_melted['date'] = pd.to_datetime(df_melted[date_col]).dt.strftime('%Y-%m-%d') + + # ์ตœ์ข… ๊ฒฐ๊ณผ + result = df_melted[['date', 'plant_id', 'generation']].copy() + result['generation'] = pd.to_numeric(result['generation'], errors='coerce').fillna(0) + + # ์ €์žฅ ์œ„์น˜ ์„ ํƒ + save_path = filedialog.asksaveasfilename( + title="๋ณ€ํ™˜ ๊ฒฐ๊ณผ ์ €์žฅ", + defaultextension=".xlsx", + initialfile="processed_data.xlsx", + filetypes=[("Excel Files", "*.xlsx")] + ) + + if not save_path: + return + + # ๋ฐœ์ „์†Œ๋ณ„๋กœ ์‹œํŠธ ๋ถ„๋ฆฌํ•˜์—ฌ ์ €์žฅ + with pd.ExcelWriter(save_path, engine='openpyxl') as writer: + # ์ „์ฒด ๋ฐ์ดํ„ฐ ์‹œํŠธ + result.to_excel(writer, sheet_name='์ „์ฒด', index=False) + + # plant_id๋ณ„ ์‹œํŠธ + for plant_id in result['plant_id'].unique(): + plant_data = result[result['plant_id'] == plant_id] + # ์‹œํŠธ๋ช…์— ์‚ฌ์šฉ ๋ถˆ๊ฐ€ํ•œ ๋ฌธ์ž ์ œ๊ฑฐ + sheet_name = plant_id.replace('/', '_').replace('\\', '_')[:31] + plant_data.to_excel(writer, sheet_name=sheet_name, index=False) + + messagebox.showinfo( + "์„ฑ๊ณต", + f"๋ณ€ํ™˜ ์™„๋ฃŒ!\n\n" + f"๐Ÿ“Š ์ด {len(result)}ํ–‰\n" + f"๐Ÿญ ๋ฐœ์ „์†Œ {len(result['plant_id'].unique())}๊ฐœ\n" + f"๐Ÿ“ ์ €์žฅ ์œ„์น˜: {save_path}" + ) + self._status(f"์ €์žฅ๋จ: {save_path}") + + except Exception as e: + messagebox.showerror("์˜ค๋ฅ˜", f"๋ณ€ํ™˜ ์‹คํŒจ:\n{e}") + import traceback + traceback.print_exc() + + def _status(self, message): + """์ƒํƒœ ๋ฉ”์‹œ์ง€ ์—…๋ฐ์ดํŠธ""" + self.status_label.config(text=message) + self.root.update_idletasks() + + +def main(): + root = tk.Tk() + app = SolarConverterApp(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/tools/solar_converter_v2.py b/tools/solar_converter_v2.py new file mode 100644 index 0000000..070a7c5 --- /dev/null +++ b/tools/solar_converter_v2.py @@ -0,0 +1,326 @@ +import customtkinter as ctk +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import pandas as pd +from pandas.tseries.offsets import MonthEnd +import json +import os +import sys + +# ํฐํŠธ ์„ค์ • +if sys.platform.startswith('win'): + FONT_BOLD = ("Malgun Gothic", 14, "bold") + FONT_NORMAL = ("Malgun Gothic", 12) + FONT_SMALL = ("Malgun Gothic", 10) +else: + FONT_BOLD = ("AppleGothic", 14, "bold") + FONT_NORMAL = ("AppleGothic", 12) + FONT_SMALL = ("AppleGothic", 10) + +ctk.set_appearance_mode("Dark") +ctk.set_default_color_theme("blue") + +class SolarApp(ctk.CTk): + def __init__(self): + super().__init__() + + self.title("ํƒœ์–‘๊ด‘ ๋ฐ์ดํ„ฐ ์ „์ฒ˜๋ฆฌ๊ธฐ V7.0 (์ฆ‰์‹œ ํ‘œ์‹œ ๋ฒ„์ „)") + self.geometry("1200x900") + + self.df_raw = None + self.header_row_idx = 0 # ๊ธฐ๋ณธ๊ฐ’ 0์œผ๋กœ ์„ค์ • + self.file_path = "" + self.mapping_file = "mapping.json" + self.mapping_entries = {} + self.saved_mapping = self.load_mapping() + + self.create_ui() + + def create_ui(self): + # 1. ์ƒ๋‹จ: ํŒŒ์ผ ๋กœ๋“œ + self.frame_top = ctk.CTkFrame(self) + self.frame_top.pack(pady=10, padx=10, fill="x") + + self.btn_load = ctk.CTkButton(self.frame_top, text="๐Ÿ“‚ 1. ์—‘์…€ ํŒŒ์ผ ์—ด๊ธฐ", + command=self.load_file, font=FONT_BOLD, height=40) + self.btn_load.pack(side="left", padx=20, pady=10) + + self.lbl_file_info = ctk.CTkLabel(self.frame_top, text="ํŒŒ์ผ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”", + text_color="gray", font=FONT_NORMAL) + self.lbl_file_info.pack(side="left", padx=10) + + # 2. ๋ฉ”์ธ ์ปจํ…์ธ  (์ขŒ: ๋ฏธ๋ฆฌ๋ณด๊ธฐ / ์šฐ: ๋งคํ•‘) + self.frame_mid = ctk.CTkFrame(self) + self.frame_mid.pack(pady=5, padx=10, fill="both", expand=True) + + # --- ์ขŒ์ธก ํŒจ๋„ (๋ฏธ๋ฆฌ๋ณด๊ธฐ) --- + self.frame_left = ctk.CTkFrame(self.frame_mid, width=700) + self.frame_left.pack(side="left", fill="both", expand=True, padx=(0, 5)) + + # ์•ˆ๋‚ด ๋ฌธ๊ตฌ ๋ฐ ์ˆ˜๋™ ๋ฒ„ํŠผ + self.frame_left_header = ctk.CTkFrame(self.frame_left, fg_color="transparent") + self.frame_left_header.pack(fill="x", pady=5) + + self.lbl_preview = ctk.CTkLabel(self.frame_left_header, + text="๐Ÿ“‚ 2. ๋ฐ์ดํ„ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", + font=FONT_BOLD, text_color="#FFA500") + self.lbl_preview.pack(side="left", padx=5) + + self.btn_set_header = ctk.CTkButton(self.frame_left_header, text="๐Ÿ‘ˆ ์„ ํƒํ•œ ์ค„์„ ์ œ๋ชฉ์œผ๋กœ ์„ค์ •", + command=self.set_header_manual, font=FONT_SMALL, + fg_color="#444444", hover_color="#666666") + self.btn_set_header.pack(side="right", padx=5) + + # Treeview + style = ttk.Style() + style.theme_use("default") + style.configure("Treeview", background="#2b2b2b", fieldbackground="#2b2b2b", + foreground="white", rowheight=25, font=FONT_NORMAL) + style.configure("Treeview.Heading", background="#3a3a3a", foreground="white", font=FONT_BOLD) + style.map('Treeview', background=[('selected', '#1f538d')]) + + self.tree = ttk.Treeview(self.frame_left) + self.tree.pack(fill="both", expand=True, padx=5, pady=5) + + vsb = ttk.Scrollbar(self.frame_left, orient="vertical", command=self.tree.yview) + vsb.pack(side='right', fill='y') + self.tree.configure(yscrollcommand=vsb.set) + + # ์ด๋ฒคํŠธ ๋ฐ”์ธ๋”ฉ + self.tree.bind("", self.on_tree_double_click) + + self.lbl_selected_header = ctk.CTkLabel(self.frame_left, text="ํ˜„์žฌ ์ œ๋ชฉ(Header): 1๋ฒˆ์งธ ์ค„ (์ž๋™์„ค์ •)", + text_color="#55FF55", font=FONT_NORMAL) + self.lbl_selected_header.pack(pady=5) + + # --- ์šฐ์ธก ํŒจ๋„ (๋งคํ•‘) --- + self.frame_right_container = ctk.CTkFrame(self.frame_mid, width=400) + self.frame_right_container.pack(side="right", fill="both", padx=(5, 0)) + + # ๋งคํ•‘ ์Šคํฌ๋กค ์˜์—ญ + self.frame_right = ctk.CTkScrollableFrame(self.frame_right_container, label_text="[๋‹จ๊ณ„ 3] ๋งคํ•‘ ์„ค์ •", + label_font=FONT_BOLD) + self.frame_right.pack(fill="both", expand=True, padx=5, pady=5) + + # 3. ํ•˜๋‹จ: ๋ณ€ํ™˜ ์‹คํ–‰ + self.frame_bottom = ctk.CTkFrame(self) + self.frame_bottom.pack(pady=10, padx=10, fill="x") + + self.progress = ctk.CTkProgressBar(self.frame_bottom) + self.progress.set(0) + self.progress.pack(fill="x", padx=20, pady=(10, 5)) + + self.btn_convert = ctk.CTkButton(self.frame_bottom, text="๐Ÿš€ ๋ณ€ํ™˜ ๋ฐ ์ €์žฅํ•˜๊ธฐ (Start)", + command=self.start_conversion, + font=FONT_BOLD, fg_color="green", state="disabled", height=50) + self.btn_convert.pack(pady=10) + + def load_file(self): + file_path = filedialog.askopenfilename(filetypes=[("Excel files", "*.xlsx *.xls *.csv")]) + if not file_path: return + + self.file_path = file_path + self.lbl_file_info.configure(text=f"...{os.path.basename(file_path)}", text_color="white") + + try: + # 1. ํŒŒ์ผ ์ฝ๊ธฐ + if file_path.endswith('.csv'): + try: self.df_raw = pd.read_csv(file_path, header=None, nrows=50, encoding='utf-8') + except: self.df_raw = pd.read_csv(file_path, header=None, nrows=50, encoding='cp949') + else: + self.df_raw = pd.read_excel(file_path, header=None, nrows=50) + + # 2. NaN ์ฒ˜๋ฆฌ (๋นˆ ๋ฌธ์ž์—ด๋กœ) + self.df_raw = self.df_raw.fillna("") + self.df_raw = self.df_raw.astype(str) + + # 3. ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ฐฑ์‹  + self.update_treeview(self.df_raw) + + # [ํ•ต์‹ฌ ๋ณ€๊ฒฝ] ํŒŒ์ผ ์—ด์ž๋งˆ์ž 0๋ฒˆ ์ค„์„ ๊ธฐ์ค€์œผ๋กœ ๋งคํ•‘ UI ๊ฐ•์ œ ์ƒ์„ฑ + self.header_row_idx = 0 + self.lbl_selected_header.configure(text="ํ˜„์žฌ ์ œ๋ชฉ(Header): 1๋ฒˆ์งธ ์ค„ (์ž๋™์„ค์ •)", text_color="#55FF55") + self.generate_mapping_ui() # <--- ์ฆ‰์‹œ ์‹คํ–‰! + + self.btn_convert.configure(state="normal") + + except Exception as e: + messagebox.showerror("์—๋Ÿฌ", f"ํŒŒ์ผ ์ฝ๊ธฐ ์‹คํŒจ: {e}") + + def update_treeview(self, df): + self.tree.delete(*self.tree.get_children()) + self.tree["columns"] = list(df.columns) + self.tree["show"] = "headings" + for col in df.columns: + self.tree.heading(col, text=f"Col {col}") + self.tree.column(col, width=80) + for i, row in df.iterrows(): + self.tree.insert("", "end", iid=str(i), values=list(row)) + + def on_tree_double_click(self, event): + item_id = self.tree.identify('item', event.x, event.y) + if not item_id: return + self.set_header_logic(int(item_id)) + + def set_header_manual(self): + # ํ˜„์žฌ ์„ ํƒ๋œ ์•„์ดํ…œ ๊ฐ€์ ธ์˜ค๊ธฐ + selected = self.tree.selection() + if not selected: + messagebox.showwarning("์•Œ๋ฆผ", "์™ผ์ชฝ ํ‘œ์—์„œ ์ œ๋ชฉ์œผ๋กœ ์“ธ ์ค„์„ ๋จผ์ € ํด๋ฆญํ•ด์„œ ์„ ํƒํ•ด์ฃผ์„ธ์š”.") + return + self.set_header_logic(int(selected[0])) + + def set_header_logic(self, row_idx): + self.header_row_idx = row_idx + self.lbl_selected_header.configure(text=f"ํ˜„์žฌ ์ œ๋ชฉ(Header): {self.header_row_idx + 1}๋ฒˆ์งธ ์ค„", text_color="#55FF55") + self.generate_mapping_ui() + + def generate_mapping_ui(self): + # ๊ธฐ์กด ์œ„์ ฏ ์‚ญ์ œ + for widget in self.frame_right.winfo_children(): + widget.destroy() + + self.mapping_entries = {} + + # ์„ ํƒ๋œ ํ–‰์˜ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ + try: + headers = self.df_raw.iloc[self.header_row_idx].values + except: + return # ๋ฐ์ดํ„ฐ๊ฐ€ ์•„์ง ์—†์„ ๋•Œ + + count = 0 + for i, col_name in enumerate(headers): + # ๋‚ด์šฉ์ด 'nan' ์ด๊ฑฐ๋‚˜ ๊ณต๋ฐฑ์ด๋ฉด ๋ฌด์‹œ (Unnmaed ๋“ฑ) + col_str = str(col_name).strip() + if not col_str or col_str.lower() == 'nan': + continue + + count += 1 + row_frame = ctk.CTkFrame(self.frame_right, fg_color="transparent") + row_frame.pack(fill="x", pady=2) + + # ๋ผ๋ฒจ (๋„ˆ๋ฌด ๊ธธ๋ฉด ์ž๋ฅด๊ธฐ) + display_name = (col_str[:15] + '..') if len(col_str) > 15 else col_str + ctk.CTkLabel(row_frame, text=f"{display_name}", width=120, anchor="w", font=FONT_NORMAL).pack(side="left", padx=5) + + entry = ctk.CTkEntry(row_frame, placeholder_text="DB ID / year / month", font=FONT_NORMAL) + entry.pack(side="right", fill="x", expand=True, padx=5) + + if col_str in self.saved_mapping: + entry.insert(0, self.saved_mapping[col_str]) + self.mapping_entries[col_str] = entry + + if count == 0: + ctk.CTkLabel(self.frame_right, text="โš ๏ธ ์„ ํƒํ•œ ์ค„์— ์œ ํšจํ•œ ๊ธ€์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.\n๋‹ค๋ฅธ ์ค„์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.", + text_color="#FF5555", font=FONT_NORMAL).pack(pady=20) + + def start_conversion(self): + self.progress.set(0.1) + self.update_idletasks() + + try: + mapping_dict = {} + special_cols = [] + + for original, entry in self.mapping_entries.items(): + target = entry.get().strip() + if target: + mapping_dict[original] = target + self.saved_mapping[original] = target + if target.lower() in ['date', 'year', 'month', 'day']: + special_cols.append(target.lower()) + + self.save_mapping() + + if not mapping_dict: + messagebox.showwarning("๊ฒฝ๊ณ ", "๋งคํ•‘๋œ ์ปฌ๋Ÿผ์ด ํ•˜๋‚˜๋„ ์—†์Šต๋‹ˆ๋‹ค.") + self.progress.set(0) + return + + self.progress.set(0.3) + + # ์‹ค์ œ ๋ฐ์ดํ„ฐ ๋‹ค์‹œ ์ฝ๊ธฐ + if self.file_path.endswith('.csv'): + try: df_real = pd.read_csv(self.file_path, header=self.header_row_idx, encoding='utf-8') + except: df_real = pd.read_csv(self.file_path, header=self.header_row_idx, encoding='cp949') + else: + df_real = pd.read_excel(self.file_path, header=self.header_row_idx) + + # ์ปฌ๋Ÿผ๋ช… ์ •๋ฆฌ + df_real.columns = [str(c).strip() for c in df_real.columns] + + # ๋งคํ•‘ ์ ์šฉ + valid_cols = [c for c in mapping_dict.keys() if c in df_real.columns] + df_sel = df_real[valid_cols].copy() + df_sel.rename(columns=mapping_dict, inplace=True) + df_sel.columns = [c.lower() if c.lower() in ['date', 'year', 'month', 'day'] else c for c in df_sel.columns] + + self.progress.set(0.5) + + # ๋‚ ์งœ ์ƒ์„ฑ ๋กœ์ง + if 'date' in df_sel.columns: + df_sel['date'] = pd.to_datetime(df_sel['date'], errors='coerce') + elif 'year' in df_sel.columns and 'month' in df_sel.columns: + df_sel['year'] = pd.to_numeric(df_sel['year'], errors='coerce') + df_sel['month'] = pd.to_numeric(df_sel['month'], errors='coerce') + + if 'day' not in df_sel.columns: + df_sel['day'] = 1 + is_monthly_data = True + else: + df_sel['day'] = pd.to_numeric(df_sel['day'], errors='coerce') + is_monthly_data = False + + df_sel.dropna(subset=['year', 'month'], inplace=True) + df_sel['date'] = pd.to_datetime(df_sel[['year', 'month', 'day']]) + + if is_monthly_data: + df_sel['date'] = df_sel['date'] + MonthEnd(0) + + df_sel.dropna(subset=['date'], inplace=True) + df_sel['date'] = df_sel['date'].dt.strftime('%Y-%m-%d') + + cols_to_drop = [c for c in ['year', 'month', 'day'] if c in df_sel.columns] + df_sel.drop(columns=cols_to_drop, inplace=True) + + self.progress.set(0.7) + + # Melt + melted = pd.melt(df_sel, id_vars=['date'], var_name='plant_id', value_name='generation') + melted['generation'] = pd.to_numeric(melted['generation'], errors='coerce') + melted.dropna(subset=['generation'], inplace=True) + melted = melted[melted['generation'] > 0] + + self.progress.set(0.9) + + # ์ €์žฅ + save_path = os.path.splitext(self.file_path)[0] + "_processed.xlsx" + with pd.ExcelWriter(save_path, engine='openpyxl') as writer: + melted.to_excel(writer, sheet_name='ALL_DATA', index=False) + for plant in melted['plant_id'].unique(): + safe_name = str(plant)[:30].replace('/', '_') + melted[melted['plant_id'] == plant].to_excel(writer, sheet_name=safe_name, index=False) + + self.progress.set(1.0) + messagebox.showinfo("์™„๋ฃŒ", f"๋ณ€ํ™˜ ์„ฑ๊ณต!\n{save_path}") + + except Exception as e: + self.progress.set(0) + messagebox.showerror("์˜ค๋ฅ˜", str(e)) + + def load_mapping(self): + if os.path.exists(self.mapping_file): + try: + with open(self.mapping_file, 'r', encoding='utf-8') as f: return json.load(f) + except: return {} + return {} + + def save_mapping(self): + try: + with open(self.mapping_file, 'w', encoding='utf-8') as f: + json.dump(self.saved_mapping, f, ensure_ascii=False, indent=4) + except: pass + +if __name__ == "__main__": + app = SolarApp() + app.mainloop() \ No newline at end of file diff --git a/tools/solar_gui_modern.py b/tools/solar_gui_modern.py new file mode 100644 index 0000000..41fe449 --- /dev/null +++ b/tools/solar_gui_modern.py @@ -0,0 +1,591 @@ +""" +solar_gui_modern.py - ํƒœ์–‘๊ด‘ ๋ฐ์ดํ„ฐ ์ „์ฒ˜๋ฆฌ๊ธฐ (Modern UI) +========================================================== +customtkinter๋ฅผ ์‚ฌ์šฉํ•œ ํ˜„๋Œ€์ ์ธ ๋””์ž์ธ์˜ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ๋„๊ตฌ + +Requirements: + pip install customtkinter pandas openpyxl +""" + +import customtkinter as ctk +from tkinter import ttk, filedialog, messagebox +import tkinter as tk +import pandas as pd +import json +import os +from pathlib import Path + +# ๋‹คํฌ ๋ชจ๋“œ ๊ธฐ๋ณธ ์„ค์ • +ctk.set_appearance_mode("Dark") +ctk.set_default_color_theme("blue") + + +class SolarApp(ctk.CTk): + """ํƒœ์–‘๊ด‘ ๋ฐ์ดํ„ฐ ์ „์ฒ˜๋ฆฌ๊ธฐ ๋ฉ”์ธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜""" + + CONFIG_FILE = "mapping.json" + + def __init__(self): + super().__init__() + + # ์œˆ๋„์šฐ ์„ค์ • + self.title("โ˜€๏ธ Solar Data Preprocessor") + self.geometry("1200x800") + self.minsize(1000, 700) + + # ์ƒํƒœ ๋ณ€์ˆ˜ + self.file_path = None + self.raw_df = None # ์›๋ณธ ๋ฐ์ดํ„ฐ (ํ—ค๋” ์—†์ด) + self.df = None # ์ฒ˜๋ฆฌ๋œ ๋ฐ์ดํ„ฐํ”„๋ ˆ์ž„ + self.header_row = 0 # ํ—ค๋”๋กœ ์ง€์ •๋œ ํ–‰ ์ธ๋ฑ์Šค + self.columns = [] + self.mapping_widgets = {} + self.saved_mappings = self._load_saved_mappings() + + self._create_ui() + + def _create_ui(self): + """UI ์ƒ์„ฑ""" + # ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) + + # === 1. ์ƒ๋‹จ: ํŒŒ์ผ ๋กœ๋“œ ์นด๋“œ === + self._create_file_card() + + # === 2. ์ค‘๋‹จ: ๋ฏธ๋ฆฌ๋ณด๊ธฐ + ๋งคํ•‘ === + self._create_main_content() + + # === 3. ํ•˜๋‹จ: ์•ก์…˜ ๋ฒ„ํŠผ === + self._create_action_bar() + + def _create_file_card(self): + """ํŒŒ์ผ ๋กœ๋“œ ์นด๋“œ UI""" + card = ctk.CTkFrame(self, corner_radius=15) + card.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew") + card.grid_columnconfigure(1, weight=1) + + # ํŒŒ์ผ ์—ด๊ธฐ ๋ฒ„ํŠผ + self.open_btn = ctk.CTkButton( + card, + text="๐Ÿ“‚ ์—‘์…€ ํŒŒ์ผ ์—ด๊ธฐ", + font=ctk.CTkFont(size=16, weight="bold"), + height=50, + width=200, + command=self._open_file + ) + self.open_btn.grid(row=0, column=0, padx=20, pady=20) + + # ํŒŒ์ผ ์ •๋ณด ์˜์—ญ + info_frame = ctk.CTkFrame(card, fg_color="transparent") + info_frame.grid(row=0, column=1, padx=20, pady=20, sticky="ew") + + self.file_name_label = ctk.CTkLabel( + info_frame, + text="ํŒŒ์ผ์„ ์„ ํƒํ•˜์„ธ์š”", + font=ctk.CTkFont(size=14), + text_color="gray" + ) + self.file_name_label.pack(anchor="w") + + self.file_path_label = ctk.CTkLabel( + info_frame, + text="", + font=ctk.CTkFont(size=11), + text_color="gray" + ) + self.file_path_label.pack(anchor="w") + + # ์ƒํƒœ ๋ผ๋ฒจ + self.status_label = ctk.CTkLabel( + card, + text="", + font=ctk.CTkFont(size=14, weight="bold"), + text_color="green" + ) + self.status_label.grid(row=0, column=2, padx=20, pady=20) + + def _create_main_content(self): + """๋ฉ”์ธ ์ฝ˜ํ…์ธ  (๋ฏธ๋ฆฌ๋ณด๊ธฐ + ๋งคํ•‘)""" + content = ctk.CTkFrame(self, fg_color="transparent") + content.grid(row=1, column=0, padx=20, pady=10, sticky="nsew") + content.grid_columnconfigure(0, weight=3) + content.grid_columnconfigure(1, weight=2) + content.grid_rowconfigure(0, weight=1) + + # === ์ขŒ์ธก: ๋ฐ์ดํ„ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ === + self._create_preview_panel(content) + + # === ์šฐ์ธก: ๋งคํ•‘ ํŒจ๋„ === + self._create_mapping_panel(content) + + def _create_preview_panel(self, parent): + """๋ฐ์ดํ„ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํŒจ๋„""" + panel = ctk.CTkFrame(parent, corner_radius=15) + panel.grid(row=0, column=0, padx=(0, 10), sticky="nsew") + panel.grid_rowconfigure(1, weight=1) + panel.grid_columnconfigure(0, weight=1) + + # ํ—ค๋” + header = ctk.CTkFrame(panel, fg_color="transparent") + header.grid(row=0, column=0, padx=15, pady=(15, 5), sticky="ew") + + ctk.CTkLabel( + header, + text="๐Ÿ“Š ๋ฐ์ดํ„ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(side="left") + + self.header_info_label = ctk.CTkLabel( + header, + text="๐Ÿ’ก ํ–‰์„ ๋”๋ธ”ํด๋ฆญํ•˜๋ฉด ํ—ค๋”๋กœ ์ง€์ •๋ฉ๋‹ˆ๋‹ค", + font=ctk.CTkFont(size=12), + text_color="gray" + ) + self.header_info_label.pack(side="right") + + # Treeview ์Šคํƒ€์ผ (๋‹คํฌ ๋ชจ๋“œ์— ๋งž์ถค) + style = ttk.Style() + style.theme_use("clam") + style.configure("Dark.Treeview", + background="#2b2b2b", + foreground="white", + fieldbackground="#2b2b2b", + rowheight=25 + ) + style.configure("Dark.Treeview.Heading", + background="#3d3d3d", + foreground="white", + font=("", 10, "bold") + ) + style.map("Dark.Treeview", + background=[("selected", "#1f538d")], + foreground=[("selected", "white")] + ) + + # Treeview + tree_frame = ctk.CTkFrame(panel, fg_color="transparent") + tree_frame.grid(row=1, column=0, padx=15, pady=(5, 15), sticky="nsew") + tree_frame.grid_rowconfigure(0, weight=1) + tree_frame.grid_columnconfigure(0, weight=1) + + self.tree = ttk.Treeview(tree_frame, style="Dark.Treeview", show="headings") + + # ์Šคํฌ๋กค๋ฐ” + scroll_y = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview) + scroll_x = ttk.Scrollbar(tree_frame, orient="horizontal", command=self.tree.xview) + self.tree.configure(xscrollcommand=scroll_x.set, yscrollcommand=scroll_y.set) + + self.tree.grid(row=0, column=0, sticky="nsew") + scroll_y.grid(row=0, column=1, sticky="ns") + scroll_x.grid(row=1, column=0, sticky="ew") + + # ๋”๋ธ”ํด๋ฆญ ์ด๋ฒคํŠธ + self.tree.bind("", self._on_row_double_click) + + def _create_mapping_panel(self, parent): + """๋งคํ•‘ ํŒจ๋„""" + panel = ctk.CTkFrame(parent, corner_radius=15) + panel.grid(row=0, column=1, padx=(10, 0), sticky="nsew") + panel.grid_rowconfigure(1, weight=1) + panel.grid_columnconfigure(0, weight=1) + + # ํ—ค๋” + header = ctk.CTkFrame(panel, fg_color="transparent") + header.grid(row=0, column=0, padx=15, pady=(15, 5), sticky="ew") + header.grid_columnconfigure(0, weight=1) + + ctk.CTkLabel( + header, + text="๐Ÿ”— ๋ฐœ์ „์†Œ ID ๋งคํ•‘", + font=ctk.CTkFont(size=16, weight="bold") + ).grid(row=0, column=0, sticky="w") + + # ๋‚ ์งœ ์ปฌ๋Ÿผ ์„ ํƒ + date_frame = ctk.CTkFrame(header, fg_color="transparent") + date_frame.grid(row=1, column=0, pady=(10, 0), sticky="ew") + + ctk.CTkLabel( + date_frame, + text="๐Ÿ“… ๋‚ ์งœ ์ปฌ๋Ÿผ:", + font=ctk.CTkFont(size=13) + ).pack(side="left") + + self.date_column_var = ctk.StringVar() + self.date_combo = ctk.CTkComboBox( + date_frame, + variable=self.date_column_var, + width=150, + state="readonly" + ) + self.date_combo.pack(side="left", padx=(10, 0)) + + # ์Šคํฌ๋กค ๊ฐ€๋Šฅํ•œ ๋งคํ•‘ ํ”„๋ ˆ์ž„ + self.mapping_scroll = ctk.CTkScrollableFrame(panel, corner_radius=10) + self.mapping_scroll.grid(row=1, column=0, padx=15, pady=(10, 15), sticky="nsew") + self.mapping_scroll.grid_columnconfigure(1, weight=1) + + # ์ดˆ๊ธฐ ์•ˆ๋‚ด ๋ฉ”์‹œ์ง€ + self.mapping_placeholder = ctk.CTkLabel( + self.mapping_scroll, + text="ํŒŒ์ผ์„ ๋กœ๋“œํ•˜๊ณ  ํ—ค๋” ํ–‰์„\n๋”๋ธ”ํด๋ฆญํ•˜์„ธ์š”", + font=ctk.CTkFont(size=14), + text_color="gray" + ) + self.mapping_placeholder.grid(row=0, column=0, columnspan=2, pady=50) + + def _create_action_bar(self): + """ํ•˜๋‹จ ์•ก์…˜ ๋ฐ”""" + bar = ctk.CTkFrame(self, corner_radius=15) + bar.grid(row=2, column=0, padx=20, pady=(10, 20), sticky="ew") + bar.grid_columnconfigure(0, weight=1) + + # ์ง„ํ–‰๋ฅ  ๋ฐ” + self.progress = ctk.CTkProgressBar(bar, width=400) + self.progress.grid(row=0, column=0, padx=20, pady=15, sticky="w") + self.progress.set(0) + + self.progress_label = ctk.CTkLabel( + bar, + text="์ค€๋น„๋จ", + font=ctk.CTkFont(size=12), + text_color="gray" + ) + self.progress_label.grid(row=0, column=1, padx=10, pady=15) + + # ๋ณ€ํ™˜ ๋ฒ„ํŠผ + self.convert_btn = ctk.CTkButton( + bar, + text="๐Ÿš€ ๋ณ€ํ™˜ ๋ฐ ์—‘์…€ ์ƒ์„ฑ", + font=ctk.CTkFont(size=16, weight="bold"), + height=45, + width=200, + fg_color="#28a745", + hover_color="#218838", + command=self._convert_and_save + ) + self.convert_btn.grid(row=0, column=2, padx=20, pady=15) + + def _open_file(self): + """ํŒŒ์ผ ์—ด๊ธฐ""" + path = filedialog.askopenfilename( + title="์—‘์…€ ํŒŒ์ผ ์„ ํƒ", + filetypes=[("Excel Files", "*.xlsx *.xls"), ("All Files", "*.*")] + ) + + if not path: + return + + self.file_path = path + file_name = os.path.basename(path) + + self.file_name_label.configure(text=f"๐Ÿ“„ {file_name}", text_color="white") + self.file_path_label.configure(text=path) + + try: + # ํ—ค๋” ์—†์ด ์›๋ณธ ๋ฐ์ดํ„ฐ ๋กœ๋“œ (์ƒ์œ„ 30ํ–‰) + self.raw_df = pd.read_excel(path, header=None, nrows=30) + self._update_preview() + + self.status_label.configure(text="โœ… ํŒŒ์ผ ๋กœ๋“œ ์™„๋ฃŒ", text_color="green") + self.header_info_label.configure( + text="๐Ÿ’ก ํ—ค๋”๋กœ ์‚ฌ์šฉํ•  ํ–‰์„ ๋”๋ธ”ํด๋ฆญํ•˜์„ธ์š”", + text_color="#ffc107" + ) + + except Exception as e: + self.status_label.configure(text=f"โŒ ๋กœ๋“œ ์‹คํŒจ", text_color="red") + messagebox.showerror("์˜ค๋ฅ˜", f"ํŒŒ์ผ ์ฝ๊ธฐ ์‹คํŒจ:\n{e}") + + def _update_preview(self): + """๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ…Œ์ด๋ธ” ์—…๋ฐ์ดํŠธ""" + if self.raw_df is None: + return + + # ๊ธฐ์กด ๋ฐ์ดํ„ฐ ์‚ญ์ œ + self.tree.delete(*self.tree.get_children()) + + # ์ปฌ๋Ÿผ ์„ค์ • (์ธ๋ฑ์Šค ํฌํ•จ) + cols = ["#"] + [f"Col {i+1}" for i in range(len(self.raw_df.columns))] + self.tree["columns"] = cols + + for col in cols: + self.tree.heading(col, text=col) + self.tree.column(col, width=100 if col != "#" else 50, minwidth=50) + + # ๋ฐ์ดํ„ฐ ์‚ฝ์ž… + for idx, row in self.raw_df.iterrows(): + values = [idx] + [str(v) if pd.notna(v) else "" for v in row] + tag = "header" if idx == self.header_row else "" + self.tree.insert("", "end", iid=str(idx), values=values, tags=(tag,)) + + # ํ—ค๋” ํ–‰ ๊ฐ•์กฐ + self.tree.tag_configure("header", background="#1f538d", foreground="white") + + def _on_row_double_click(self, event): + """ํ–‰ ๋”๋ธ”ํด๋ฆญ ์‹œ ํ—ค๋”๋กœ ์ง€์ •""" + # ํด๋ฆญํ•œ ์œ„์น˜์—์„œ ํ–‰ ์‹๋ณ„ + item = self.tree.identify_row(event.y) + if not item: + return + + try: + self.header_row = int(item) + except ValueError: + return + + self._update_preview() + + self.header_info_label.configure( + text=f"โœ… ํ—ค๋” ์ง€์ •๋จ: {self.header_row}๋ฒˆ์งธ ํ–‰", + text_color="green" + ) + + # ์‹ค์ œ ๋ฐ์ดํ„ฐํ”„๋ ˆ์ž„ ์ƒ์„ฑ + try: + self.df = pd.read_excel( + self.file_path, + skiprows=self.header_row, + header=0 + ) + self.columns = [str(c) for c in self.df.columns] + self._update_mapping_ui() + + except Exception as e: + messagebox.showerror("์˜ค๋ฅ˜", f"๋ฐ์ดํ„ฐ ํŒŒ์‹ฑ ์‹คํŒจ:\n{e}") + + def _update_mapping_ui(self): + """๋งคํ•‘ UI ์—…๋ฐ์ดํŠธ""" + # ๊ธฐ์กด ์œ„์ ฏ ์ œ๊ฑฐ + for widget in self.mapping_scroll.winfo_children(): + widget.destroy() + self.mapping_widgets = {} + + # ๋‚ ์งœ ์ปฌ๋Ÿผ ์ฝค๋ณด๋ฐ•์Šค ์—…๋ฐ์ดํŠธ + self.date_combo.configure(values=self.columns) + for col in self.columns: + if any(kw in col.lower() for kw in ['date', '๋‚ ์งœ', '์ผ์ž']): + self.date_column_var.set(col) + break + else: + if self.columns: + self.date_column_var.set(self.columns[0]) + + # ํ—ค๋” + ctk.CTkLabel( + self.mapping_scroll, + text="์ปฌ๋Ÿผ๋ช…", + font=ctk.CTkFont(size=12, weight="bold"), + text_color="gray" + ).grid(row=0, column=0, padx=5, pady=(0, 10), sticky="w") + + ctk.CTkLabel( + self.mapping_scroll, + text="Plant ID", + font=ctk.CTkFont(size=12, weight="bold"), + text_color="gray" + ).grid(row=0, column=1, padx=5, pady=(0, 10), sticky="w") + + ctk.CTkLabel( + self.mapping_scroll, + text="์ œ์™ธ", + font=ctk.CTkFont(size=12, weight="bold"), + text_color="gray" + ).grid(row=0, column=2, padx=5, pady=(0, 10)) + + # ๊ฐ ์ปฌ๋Ÿผ ๋งคํ•‘ ํ–‰ + for i, col in enumerate(self.columns): + row_idx = i + 1 + + # ์ปฌ๋Ÿผ๋ช… + ctk.CTkLabel( + self.mapping_scroll, + text=col[:30] + "..." if len(col) > 30 else col, + font=ctk.CTkFont(size=13) + ).grid(row=row_idx, column=0, padx=5, pady=5, sticky="w") + + # Plant ID ์ž…๋ ฅ + id_var = ctk.StringVar() + id_entry = ctk.CTkEntry( + self.mapping_scroll, + textvariable=id_var, + width=120, + placeholder_text="nrems-01" + ) + id_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + + # ์ œ์™ธ ์ฒดํฌ๋ฐ•์Šค + exclude_var = ctk.BooleanVar(value=False) + exclude_chk = ctk.CTkCheckBox( + self.mapping_scroll, + text="", + variable=exclude_var, + width=30 + ) + exclude_chk.grid(row=row_idx, column=2, padx=5, pady=5) + + # ์ž๋™ ๋งคํ•‘ ์ ์šฉ + suggested = self._get_saved_mapping(col) + if suggested: + id_var.set(suggested) + else: + # ํ˜ธ๊ธฐ ๋ฒˆํ˜ธ ์ถ”์ถœ ์‹œ๋„ + import re + match = re.search(r'(\d+)\s*ํ˜ธ๊ธฐ?', col) + if match: + num = match.group(1).zfill(2) + id_var.set(f"nrems-{num}") + elif any(kw in col.lower() for kw in ['date', '๋‚ ์งœ', '์ผ์ž']): + exclude_var.set(True) + + self.mapping_widgets[col] = { + 'id_var': id_var, + 'exclude_var': exclude_var + } + + def _get_saved_mapping(self, column_name): + """์ €์žฅ๋œ ๋งคํ•‘์—์„œ ๊ฒ€์ƒ‰""" + return self.saved_mappings.get(column_name, "") + + def _load_saved_mappings(self): + """์ €์žฅ๋œ ๋งคํ•‘ ๋กœ๋“œ""" + if os.path.exists(self.CONFIG_FILE): + try: + with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except: + pass + return {} + + def _save_mappings(self): + """ํ˜„์žฌ ๋งคํ•‘ ์ €์žฅ""" + mappings = {} + for col, widgets in self.mapping_widgets.items(): + plant_id = widgets['id_var'].get().strip() + if plant_id: + mappings[col] = plant_id + + try: + with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(mappings, f, ensure_ascii=False, indent=2) + except: + pass + + def _convert_and_save(self): + """๋ณ€ํ™˜ ๋ฐ ์ €์žฅ""" + if self.df is None or self.df.empty: + messagebox.showerror("์˜ค๋ฅ˜", "๋จผ์ € ํŒŒ์ผ์„ ๋กœ๋“œํ•˜๊ณ  ํ—ค๋”๋ฅผ ์ง€์ •ํ•˜์„ธ์š”.") + return + + date_col = self.date_column_var.get() + if not date_col or date_col not in self.df.columns: + messagebox.showerror("์˜ค๋ฅ˜", "๋‚ ์งœ ์ปฌ๋Ÿผ์„ ์„ ํƒํ•˜์„ธ์š”.") + return + + # ๋งคํ•‘ ์ˆ˜์ง‘ + mappings = {} + for col, widgets in self.mapping_widgets.items(): + if not widgets['exclude_var'].get(): + plant_id = widgets['id_var'].get().strip() + if plant_id: + mappings[col] = plant_id + + if not mappings: + messagebox.showerror("์˜ค๋ฅ˜", "์ตœ์†Œ ํ•˜๋‚˜์˜ ์ปฌ๋Ÿผ์„ ๋งคํ•‘ํ•˜์„ธ์š”.") + return + + # ๋งคํ•‘ ์ €์žฅ + self._save_mappings() + + try: + self.progress.set(0.1) + self.progress_label.configure(text="๋ฐ์ดํ„ฐ ์ถ”์ถœ ์ค‘...") + self.update() + + # ํ•„์š”ํ•œ ์ปฌ๋Ÿผ ์ถ”์ถœ + cols_to_use = [date_col] + list(mappings.keys()) + df_subset = self.df[cols_to_use].copy() + + self.progress.set(0.3) + self.progress_label.configure(text="๊ตฌ์กฐ ๋ณ€ํ™˜ ์ค‘...") + self.update() + + # melt ๋ณ€ํ™˜ + df_melted = df_subset.melt( + id_vars=[date_col], + var_name='excel_column', + value_name='generation' + ) + + # plant_id ๋งคํ•‘ + df_melted['plant_id'] = df_melted['excel_column'].map(mappings) + + # ๋‚ ์งœ ํ‘œ์ค€ํ™” + df_melted['date'] = pd.to_datetime(df_melted[date_col], errors='coerce').dt.strftime('%Y-%m-%d') + + # ๊ฒฐ๊ณผ ์ •๋ฆฌ + result = df_melted[['date', 'plant_id', 'generation']].copy() + result['generation'] = pd.to_numeric(result['generation'], errors='coerce').fillna(0) + result = result.dropna(subset=['date']) + + self.progress.set(0.5) + self.progress_label.configure(text="ํŒŒ์ผ ์ €์žฅ ์ค‘...") + self.update() + + # ์ €์žฅ ๊ฒฝ๋กœ + original_name = Path(self.file_path).stem + save_path = filedialog.asksaveasfilename( + title="๋ณ€ํ™˜ ๊ฒฐ๊ณผ ์ €์žฅ", + defaultextension=".xlsx", + initialfile=f"processed_{original_name}.xlsx", + filetypes=[("Excel Files", "*.xlsx")] + ) + + if not save_path: + self.progress.set(0) + self.progress_label.configure(text="์ทจ์†Œ๋จ") + return + + # ์—‘์…€ ์ €์žฅ (์‹œํŠธ๋ณ„ ๋ถ„๋ฆฌ) + plant_ids = result['plant_id'].unique() + total_plants = len(plant_ids) + + with pd.ExcelWriter(save_path, engine='openpyxl') as writer: + # ์ „์ฒด ์‹œํŠธ + result.to_excel(writer, sheet_name='์ „์ฒด', index=False) + + # ๋ฐœ์ „์†Œ๋ณ„ ์‹œํŠธ + for i, plant_id in enumerate(plant_ids): + plant_data = result[result['plant_id'] == plant_id] + sheet_name = str(plant_id).replace('/', '_')[:31] + plant_data.to_excel(writer, sheet_name=sheet_name, index=False) + + progress = 0.5 + (0.5 * (i + 1) / total_plants) + self.progress.set(progress) + self.update() + + self.progress.set(1.0) + self.progress_label.configure(text="์™„๋ฃŒ!", text_color="green") + + messagebox.showinfo( + "๋ณ€ํ™˜ ์™„๋ฃŒ! ๐ŸŽ‰", + f"๐Ÿ“Š ์ด {len(result):,}ํ–‰ ๋ณ€ํ™˜\n" + f"๐Ÿญ ๋ฐœ์ „์†Œ {total_plants}๊ฐœ\n" + f"๐Ÿ“ ์ €์žฅ: {save_path}" + ) + + except Exception as e: + self.progress.set(0) + self.progress_label.configure(text="์˜ค๋ฅ˜ ๋ฐœ์ƒ", text_color="red") + messagebox.showerror("์˜ค๋ฅ˜", f"๋ณ€ํ™˜ ์‹คํŒจ:\n{e}") + import traceback + traceback.print_exc() + + +def main(): + app = SolarApp() + app.mainloop() + + +if __name__ == "__main__": + main()