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()