import json # TAMBAHKAN get_object_or_404 di baris ini from django.shortcuts import render, get_object_or_404 from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.utils import timezone from django.db.models.functions import TruncHour, TruncDay, TruncMonth from django.db.models import Max, Min, Avg, Sum from .models import KwhReadings, Mesin # TAMBAHKAN baris import ini untuk menangani dekorator @require_POST from django.views.decorators.http import require_POST # TAMBAHKAN baris import ini untuk Export excel import openpyxl from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from django.http import HttpResponse from .models import Mesin, Area import uuid import os # Ditambahkan untuk membaca file path lokal di server from django.conf import settings # Ditambahkan untuk mencari jalur folder static from openpyxl.drawing.image import Image as OpenpyxlImage # Modul untuk menyisipkan gambar # TAMBAHKAN baris import ini untuk date custom from django.utils.dateparse import parse_datetime # Import pdf from django.shortcuts import render # Halaman pertama aksses ke Login from django.contrib.auth.decorators import login_required from django.shortcuts import render # 1. View untuk menampilkan halaman HTML Log Data def log_kwh_view(request): return render(request, 'dashboard/log_kwh.html') def api_log_data(request): period = request.GET.get('period', 'hourly') start_date_str = request.GET.get('start_date') end_date_str = request.GET.get('end_date') # 1. Ambil parameter mesin_id dari URL mesin_id = request.GET.get('mesin_id', None) queryset = KwhReadings.objects.all() # 2. Filter data berdasarkan mesin_id jika user memilih salah satu mesin secara spesifik if mesin_id and mesin_id.strip() != '' and mesin_id != 'all' and mesin_id != 'undefined': queryset = queryset.filter(mesin_id=mesin_id) use_max_diff_logic = False # Penanda apakah perlu pakai logika selisih antar periode # ─── LOGIKA CUSTOM DATE YANG FLEKSIBEL (Tetap Utuh) ─── if period == 'custom' and start_date_str and end_date_str: start_date = parse_datetime(start_date_str) end_date = parse_datetime(end_date_str) if start_date and end_date: queryset = queryset.filter(timestamp__range=(start_date, end_date)) selisih_waktu = end_date - start_date total_hari = selisih_waktu.days if total_hari > 31: trunc_func = TruncMonth('timestamp') date_format = '%b %Y' elif total_hari >= 2: trunc_func = TruncDay('timestamp') date_format = '%d %b %Y' else: trunc_func = TruncHour('timestamp') date_format = '%H:%M (%d %b)' use_max_diff_logic = True # Custom rentang jam butuh logika ini limit = None # ─── KONDISI DEFAULT (TOMBOL NAVIGASI - Tetap Utuh) ─── elif period == 'daily': trunc_func = TruncDay('timestamp') date_format = '%d %b %Y' limit = 30 elif period == 'monthly': trunc_func = TruncMonth('timestamp') date_format = '%b %Y' limit = 12 else: # Default 'hourly' trunc_func = TruncHour('timestamp') date_format = '%H:%M (%d %b)' limit = 24 use_max_diff_logic = True # Mode per jam wajib menggunakan logika ini # ─── PROSES AGREGASI DATABASE ─── logs_query = ( queryset .annotate(period_label=trunc_func) .values('period_label') .annotate( max_val=Max('active_power_kwh'), min_val=Min('active_power_kwh') ) ) # 💡 PINTAR: Semua mode yang memiliki limit (hourly, daily, monthly) # sekarang mengambil 1 data ekstra ke belakang untuk acuan nilai sebelumnya. if limit: logs = logs_query.order_by('-period_label')[:limit + 1] logs = list(logs) logs.reverse() # Urutkan maju kronologis (Past lama -> Baru) SEBELUM dihitung else: logs = logs_query.order_by('period_label') labels = [] values = [] prev_max_kwh = None for entry in logs: if entry['period_label']: max_kwh = entry['max_val'] or 0 min_kwh = entry['min_val'] or 0 # 💡 ANTI-MENGUAP: Berlaku untuk per jam, per hari, per bulan, dan kustom rentang jam if use_max_diff_logic or period in ['daily', 'monthly']: if prev_max_kwh is None: # Baris paling pertama/tertua di grafik menggunakan selisih internalnya sendiri konsumsi_asli = max_kwh - min_kwh else: # Baris berikutnya selalu dikurangi nilai MAX periode sebelumnya konsumsi_asli = max_kwh - prev_max_kwh # Pengaman jika kWh meter fisik diganti baru atau di-reset ke 0 if konsumsi_asli < 0: konsumsi_asli = max_kwh - min_kwh else: # Fallback untuk kustom range yang sangat panjang (>2 hari) konsumsi_asli = max_kwh - min_kwh if konsumsi_asli < 0: konsumsi_asli = max_kwh labels.append(entry['period_label'].strftime(date_format)) values.append(round(konsumsi_asli, 4)) # Kunci utama: Simpan Max periode ini untuk pengurang periode selanjutnya prev_max_kwh = max_kwh # Potong data ekstra indeks pertama agar total batang grafik pas sesuai limit asli (24, 30, atau 12) if limit and len(values) > limit: labels = labels[1:] values = values[1:] # ─── FITUR BARU: HITUNG JUMLAH TOTAL AKUMULASI KONSUMSI GRAFIK ─── total_sum = sum(values) # 3. KONTROL PENCEGAHAN GRAFIK KOSONG if not labels: return JsonResponse({'labels': ['Belum Ada Data'], 'values': [], 'total_sum': 0}) # Kembalikan response lengkap termasuk total_sum baru ke frontend return JsonResponse({ 'labels': labels, 'values': values, 'total_sum': round(total_sum, 4) }) # 2. Karena data diambil dari yang terbaru (mundur), kita balik (reverse) # agar di grafik Chart.js tampilannya berurutan maju (kronologis) logs = list(logs) logs.reverse() labels = [] values = [] for entry in logs: if entry['period_label']: labels.append(entry['period_label'].strftime(date_format)) # Pengaman jika nilai Max atau Min None max_kwh = entry['max_val'] or 0 min_kwh = entry['min_val'] or 0 # Logika dasar Max - Min konsumsi_asli = max_kwh - min_kwh # ANTISIPASI RESET: Jika kWh sempat di-reset ke 0 oleh ESP32 # (Gunakan ini hanya jika ESP32 Anda sering reset/mati) if konsumsi_asli < 0: konsumsi_asli = max_kwh # Mengasumsikan nilai setelah reset dimulai dari 0 values.append(round(konsumsi_asli, 4)) return JsonResponse({'labels': labels, 'values': values}) # 3. View untuk menampilkan halaman HTML Dashboard Utama def dashboard_view(request): # Mengambil semua daftar mesin untuk dikirim ke base.html (jika belum menggunakan context processor) mesin_global_list = Mesin.objects.select_related('area').all() context = { 'mesin_global_list': mesin_global_list } return render(request, 'dashboard/index.html', context) # 4. View API untuk menampilkan 144 data realtime terakhir di halaman depan (Terfilter) 24 jam terakhir def api_latest_data(request): # 1. Tangkap parameter 'mesin_id' dari JavaScript fetch melalui URL mesin_id = request.GET.get('mesin_id', None) # 2. Ambil 144 data TERBARU berdasarkan ID terbesar (-id) agar data yang mati berjam-jam tidak terlewat if mesin_id and mesin_id.strip() != '' and mesin_id != 'all': # Jika memilih mesin tertentu, ambil 144 data terakhir milik mesin tersebut raw_data = KwhReadings.objects.filter(mesin_id=mesin_id).order_by('-id')[:144] else: # Jika global, ambil 144 data terakhir campuran secara default raw_data = KwhReadings.objects.all().order_by('-id')[:144] # 3. BALIKKAN urutan list data (dari Desending ke Ascending) # Ini WAJIB agar data yang paling baru posisinya bergeser ke sisi paling kanan grafik Chart.js data_list = list(reversed(raw_data)) # Bungkus data ke dalam list kosong (Sama persis seperti struktur asli Anda) timestamps = [] apparent_power = [] total_current = [] power_factor = [] active_power = [] active_power_kwh = [] # Iterasi data untuk dimasukkan ke struktur JSON grafik for item in data_list: # Konversi waktu ke jam lokal agar tampilan teks di sumbu X grafik sesuai WIB lokal_time = timezone.localtime(item.timestamp) if timezone.is_aware(item.timestamp) else item.timestamp timestamps.append(lokal_time.strftime('%d %b %H:%M')) apparent_power.append(item.apparent_power_va) total_current.append(item.total_current_a) power_factor.append(item.power_factor) active_power.append(round(item.real_active_power_kw, 4)) active_power_kwh.append(round(item.active_power_kwh, 4)) # Kembalikan response berupa format JSON terstruktur ke frontend return JsonResponse({ 'timestamps': timestamps, 'apparent_power': apparent_power, 'total_current': total_current, 'power_factor': power_factor, 'active_power': active_power, 'active_power_kwh': active_power_kwh }) # 5. Endpoint penerima kiriman data dari ESP32 (SINKRON 100%) @csrf_exempt def receive_kwh_data(request): if request.method == 'POST': # 1. Ambil token keamanan dari HTTP Header token_diterima = request.META.get('HTTP_X_ESP32_TOKEN') if not token_diterima: return JsonResponse({'status': 'error', 'message': 'Token keamanan tidak ditemukan!'}, status=401) try: data = json.loads(request.body) # 2. Ambil nilai device_id dari JSON payload ESP32 device_id_diterima = data.get('device_id') if not device_id_diterima: return JsonResponse({'status': 'error', 'message': 'JSON payload harus menyertakan device_id'}, status=400) # 3. VALIDASI GANDA: Cocokkan Token AND Nama Mesin di Database mesin_terdaftar = Mesin.objects.filter( token=token_diterima, nama_mesin=device_id_diterima ).first() # Jika kombinasi token dan nama tidak cocok, langsung tolak if not mesin_terdaftar: return JsonResponse({ 'status': 'error', 'message': 'Validasi Gagal! Kombinasi Token dan Nama Mesin tidak cocok atau tidak terdaftar.' }, status=403) # 4. Ambil data sensor lainnya active_power_kwh = float(data.get('active_power_kwh', 0)) apparent_power_va = float(data.get('apparent_power_va', 0)) power_factor = float(data.get('power_factor', 0)) total_current_a = float(data.get('total_current_a', 0)) # Hitung Nilai Daya Aktif Sesaat (kW) real_active_power_kw = (apparent_power_va * power_factor) / 1000.0 # 5. Simpan ke database dengan menempelkan objek mesin yang sudah divalidasi KwhReadings.objects.create( mesin=mesin_terdaftar, # Mengisi kolom ForeignKey dengan ID Mesin secara otomatis apparent_power_va=apparent_power_va, total_current_a=total_current_a, power_factor=power_factor, active_power_kwh=active_power_kwh, real_active_power_kw=real_active_power_kw ) return JsonResponse({ 'status': 'success', 'message': f'Data untuk mesin {mesin_terdaftar.nama_mesin} sukses disimpan ke database' }, status=201) except Exception as e: print("\n❌ [ERROR DATABASE DJANGO]:", str(e), "\n") return JsonResponse({'status': 'error', 'message': str(e)}, status=400) return JsonResponse({'status': 'invalid method'}, status=405) # Export Excel KWH def export_kwh_excel(request): # 1. Ambil parameter dari request period = request.GET.get('period', 'hourly') start_date_str = request.GET.get('start_date') end_date_str = request.GET.get('end_date') mesin_id = request.GET.get('mesin_id', None) queryset = KwhReadings.objects.all() limit = None # Ambil Informasi Lengkap Mesin & Area nama_mesin_info = "Semua Unit Mesin" area_info = "-" keterangan_info = "-" if mesin_id and mesin_id.strip() != '' and mesin_id != 'all' and mesin_id != 'undefined': queryset = queryset.filter(mesin_id=mesin_id) try: mesin_obj = Mesin.objects.select_related('area').get(id=mesin_id) nama_mesin_info = mesin_obj.nama_mesin keterangan_info = mesin_obj.keterangan or "-" if mesin_obj.area: area_info = mesin_obj.area.nama_area except Mesin.DoesNotExist: pass # 2. Logika Filter & Agregasi Sesuai Grafik (Hourly, Daily, Monthly, Custom) if period == 'custom' and start_date_str and end_date_str: start_date = parse_datetime(start_date_str) end_date = parse_datetime(end_date_str) if start_date and end_date: queryset = queryset.filter(timestamp__range=(start_date, end_date)) selisih_waktu = end_date - start_date total_hari = selisih_waktu.days if total_hari > 31: trunc_func = TruncMonth('timestamp') date_format = '%d-%m-%Y' title_period = "Kustom Bulanan" elif total_hari >= 2: trunc_func = TruncDay('timestamp') date_format = '%d-%m-%Y' title_period = "Kustom Harian" else: trunc_func = TruncHour('timestamp') date_format = '%d-%m-%Y %H:00' title_period = "Kustom Per Jam" elif period == 'daily': trunc_func = TruncDay('timestamp') date_format = '%d-%m-%Y' title_period = "Harian (30 Data Terakhir)" limit = 30 elif period == 'monthly': trunc_func = TruncMonth('timestamp') date_format = '%m-%Y' title_period = "Bulanan (12 Data Terakhir)" limit = 12 else: trunc_func = TruncHour('timestamp') date_format = '%d-%m-%Y %H:00' title_period = "Per Jam (24 Data Terakhir)" limit = 24 # 3. Proses Agregasi Database logs_query = ( queryset .annotate(period_label=trunc_func) .values('period_label') .annotate( max_val=Max('active_power_kwh'), min_val=Min('active_power_kwh') ) ) if limit: logs = logs_query.order_by('-period_label')[:limit] logs = list(logs) logs.reverse() else: logs = logs_query.order_by('period_label') # 4. Inisialisasi Workbook Excel & Styling wb = openpyxl.Workbook() ws = wb.active ws.title = "Report kWh" # Font & Style Setup title_font = Font(name='Calibri', size=14, bold=True, color='1F2937') company_font = Font(name='Calibri', size=11, bold=True, color='4B5563') info_font_bold = Font(name='Calibri', size=11, bold=True) info_font_regular = Font(name='Calibri', size=11, bold=False) header_font = Font(name='Calibri', size=11, bold=True, color='FFFFFF') header_fill = PatternFill(start_color='1F2937', end_color='1F2937', fill_type='solid') align_center = Alignment(horizontal='center', vertical='center') align_left = Alignment(horizontal='left', vertical='center') border_thin = Border( left=Side(style='thin', color='D1D5DB'), right=Side(style='thin', color='D1D5DB'), top=Side(style='thin', color='D1D5DB'), bottom=Side(style='thin', color='D1D5DB') ) # ─── FITUR LOGO PERUSAHAAN DI CELL A1 ─── logo_path = os.path.join(settings.BASE_DIR, 'dashboard', 'static', 'images', 'FLN.png') if os.path.exists(logo_path): img = OpenpyxlImage(logo_path) target_height = 55 target_width = int((img.width / img.height) * target_height) img.width = target_width img.height = target_height ws.add_image(img, 'A1') ws.row_dimensions[1].height = 25 ws.row_dimensions[2].height = 20 ws.row_dimensions[3].height = 15 # ─── TEKS JUDUL KOLOM B ─── ws['B1'] = "Sistem Informasi Monitoring Energi" ws['B1'].font = title_font ws['B2'] = "PT. Frina Lestari Nusantara" ws['B2'].font = company_font # ─── BLOK INFORMASI MESIN BARIS 5 - 7 ─── ws['A5'] = "Nama Mesin:" ws['B5'] = nama_mesin_info ws['A6'] = "Area/Lokasi:" ws['B6'] = area_info ws['A7'] = "Keterangan:" ws['B7'] = keterangan_info for r in range(5, 8): ws.cell(row=r, column=1).font = info_font_bold ws.cell(row=r, column=2).font = info_font_regular ws.cell(row=r, column=1).alignment = align_left ws.cell(row=r, column=2).alignment = align_left # 5. ─── TABEL UTAMA PADA BARIS KE-9 ─── headers = ['No', f'Periode ({title_period})', 'Min kWh', 'Max kWh', 'Total Konsumsi (kWh)'] for col_num, header in enumerate(headers, 1): cell = ws.cell(row=9, column=col_num) cell.value = header cell.font = header_font cell.fill = header_fill cell.alignment = align_center # 6. ─── ISI DATA ROW MULAI DARI BARIS KE-10 ─── row_num = 10 total_excel_kwh = 0 prev_max_kwh = None # Pembantu kalkulasi rentang antar jam # Ambil logs yang sudah diurutkan dari database for idx, entry in enumerate(logs, 1): if entry['period_label']: min_kwh = entry['min_val'] or 0 max_kwh = entry['max_val'] or 0 if prev_max_kwh is None: konsumsi_asli = max_kwh - min_kwh else: konsumsi_asli = max_kwh - prev_max_kwh if konsumsi_asli < 0: konsumsi_asli = max_kwh - min_kwh date_str = entry['period_label'].strftime(date_format) total_excel_kwh += konsumsi_asli row = [idx, date_str, round(min_kwh, 4), round(max_kwh, 4), round(konsumsi_asli, 4)] for col_num, value in enumerate(row, 1): cell = ws.cell(row=row_num, column=col_num) cell.value = value cell.border = border_thin if col_num in (1, 2): cell.alignment = align_center row_num += 1 prev_max_kwh = max_kwh # Update penanda data sebelumnya # ─── FITUR BARU: SUNTIKKAN BARIS REKAP TOTAL DI PALING BAWAH TABEL EXCEL ─── # Satukan kolom A sampai D (kolom 1 sampai 4) untuk teks label total ws.merge_cells(start_row=row_num, start_column=1, end_row=row_num, end_column=4) cell_label = ws.cell(row=row_num, column=1) cell_label.value = "TOTAL KONSUMSI ENERGI" cell_label.font = Font(name='Calibri', size=11, bold=True) cell_label.alignment = Alignment(horizontal='right', vertical='center') # Isi angka total kWh di kolom E (kolom 5) dan beri warna background amber/kuning soft cell_total = ws.cell(row=row_num, column=5) cell_total.value = round(total_excel_kwh, 4) cell_total.font = Font(name='Calibri', size=11, bold=True) cell_total.fill = PatternFill(start_color='FEF3C7', end_color='FEF3C7', fill_type='solid') # Aplikasikan border luar untuk seluruh kolom pada baris total tersebut for col_idx in range(1, 6): ws.cell(row=row_num, column=col_idx).border = border_thin # Autofit lebar kolom (Bebas Eror) for col in ws.columns: max_len = max(len(str(cell.value or '')) for cell in col) col_letter = openpyxl.utils.get_column_letter(col[0].column) ws.column_dimensions[col_letter].width = max(max_len + 4, 12) # ─── COPYRIGHT PADA FOOTER CETAK DOKUMEN (KIRI BAWAH) ─── ws.oddFooter.left.text = "Copyright System IT-Dept-2026 | PT. Frina Lestari Nusantara" ws.oddFooter.left.size = 9 ws.oddFooter.left.font = "Calibri" # 7. Response Download Sheet response = HttpResponse( content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) filename = f"Report_kWh_{period}_{timezone.now().strftime('%Y%m%d_%H%M')}.xlsx" response['Content-Disposition'] = f'attachment; filename="{filename}"' wb.save(response) return response # 1. View Render Halaman Analisis Grafik def analisis_grafik_view(request): return render(request, 'dashboard/analisis_grafik.html') # 2. API Data Analisis (Rata-rata Apparent, PF, Current per Periode) def api_analysis_data(request): period = request.GET.get('period', 'hourly') start_date_str = request.GET.get('start_date') end_date_str = request.GET.get('end_date') # 1. Ambil parameter mesin_id dari URL mesin_id = request.GET.get('mesin_id', None) queryset = KwhReadings.objects.all() limit = None # 2. Filter data berdasarkan mesin_id jika user memilih salah satu mesin secara spesifik if mesin_id and mesin_id.strip() != '' and mesin_id != 'all' and mesin_id != 'undefined': queryset = queryset.filter(mesin_id=mesin_id) # ─── LOGIKA CUSTOM DATE YANG FLEKSIBEL ─── if period == 'custom' and start_date_str and end_date_str: start_date = parse_datetime(start_date_str) end_date = parse_datetime(end_date_str) if start_date and end_date: queryset = queryset.filter(timestamp__range=(start_date, end_date)) selisih_waktu = end_date - start_date total_hari = selisih_waktu.days if total_hari > 31: trunc_func = TruncMonth('timestamp') date_format = '%b %Y' elif total_hari >= 2: trunc_func = TruncDay('timestamp') date_format = '%d %b %Y' else: trunc_func = TruncHour('timestamp') date_format = '%H:%M (%d %b)' # ─── KONDISI DEFAULT DENGAN BATASAN LIMIT DATA ─── elif period == 'daily': trunc_func = TruncDay('timestamp') date_format = '%d %b %Y' limit = 7 elif period == 'monthly': trunc_func = TruncMonth('timestamp') date_format = '%b %Y' limit = 12 else: # Default 'hourly' trunc_func = TruncHour('timestamp') date_format = '%H:%M (%d %b)' limit = 24 # ─── PROSES AGREGASI DATABASE (RATA-RATA) ─── logs_query = ( queryset .annotate(period_label=trunc_func) .values('period_label') .annotate( avg_apparent=Avg('apparent_power_va'), avg_pf=Avg('power_factor'), avg_current=Avg('total_current_a') ) ) if limit: logs = logs_query.order_by('-period_label')[:limit] logs = list(logs) logs.reverse() else: logs = logs_query.order_by('period_label') labels = [] apparent_power = [] power_factor = [] total_current = [] for entry in logs: if entry['period_label']: labels.append(entry['period_label'].strftime(date_format)) apparent_power.append(round(entry['avg_apparent'] or 0, 2)) power_factor.append(round(entry['avg_pf'] or 0, 3)) total_current.append(round(entry['avg_current'] or 0, 4)) # Proteksi jika hasil query kosong agar Chart.js frontend tidak rusak # ─── KODE PERBAIKAN UNTUK FUNGSI api_analysis_data ─── if not labels: return JsonResponse({ 'labels': ['Belum Ada Data'], 'apparent_power': [], 'power_factor': [], 'total_current': [] }) return JsonResponse({ 'labels': labels, 'apparent_power': apparent_power, 'power_factor': power_factor, 'total_current': total_current }) # 3. Export Excel Data Analisis Parameter # Export Excel Analisis Parameter Listrik def export_analysis_excel(request): period = request.GET.get('period', 'hourly') start_date_str = request.GET.get('start_date') end_date_str = request.GET.get('end_date') mesin_id = request.GET.get('mesin_id', None) queryset = KwhReadings.objects.all() limit = None # Ambil Informasi Lengkap Mesin & Area nama_mesin_info = "Semua Unit Mesin" area_info = "-" keterangan_info = "-" if mesin_id and mesin_id.strip() != '' and mesin_id != 'all' and mesin_id != 'undefined': queryset = queryset.filter(mesin_id=mesin_id) try: mesin_obj = Mesin.objects.select_related('area').get(id=mesin_id) nama_mesin_info = mesin_obj.nama_mesin keterangan_info = mesin_obj.keterangan or "-" if mesin_obj.area: area_info = mesin_obj.area.nama_area except Mesin.DoesNotExist: pass # ─── LOGIKA FILTER & AGREGASI SINKRON DENGAN GRAFIK ─── if period == 'custom' and start_date_str and end_date_str: start_date = parse_datetime(start_date_str) end_date = parse_datetime(end_date_str) if start_date and end_date: queryset = queryset.filter(timestamp__range=(start_date, end_date)) selisih_waktu = end_date - start_date total_hari = selisih_waktu.days if total_hari > 31: trunc_func = TruncMonth('timestamp') date_format = '%d-%m-%Y' title_period = "Kustom Bulanan" elif total_hari >= 2: trunc_func = TruncDay('timestamp') date_format = '%d-%m-%Y' title_period = "Kustom Harian" else: trunc_func = TruncHour('timestamp') date_format = '%d-%m-%Y %H:00' title_period = "Kustom Per Jam" elif period == 'daily': trunc_func = TruncDay('timestamp') date_format = '%d-%m-%Y' title_period = "Harian (7 Data Terakhir)" limit = 7 elif period == 'monthly': trunc_func = TruncMonth('timestamp') date_format = '%m-%Y' title_period = "Bulanan (12 Data Terakhir)" limit = 12 else: # Default 'hourly' trunc_func = TruncHour('timestamp') date_format = '%d-%m-%Y %H:00' title_period = "Per Jam (24 Data Terakhir)" limit = 24 # ─── PROSES AGREGASI DATABASE (AVERAGE) ─── logs_query = ( queryset .annotate(period_label=trunc_func) .values('period_label') .annotate( avg_apparent=Avg('apparent_power_va'), avg_pf=Avg('power_factor'), avg_current=Avg('total_current_a') ) ) if limit: logs = logs_query.order_by('-period_label')[:limit] logs = list(logs) logs.reverse() else: logs = logs_query.order_by('period_label') # ─── INISIALISASI WORKBOOK EXCEL & STYLING ─── wb = openpyxl.Workbook() ws = wb.active ws.title = "Analisis Parameter" # Font & Style Setup title_font = Font(name='Calibri', size=14, bold=True, color='1F2937') company_font = Font(name='Calibri', size=11, bold=True, color='4B5563') info_font_bold = Font(name='Calibri', size=11, bold=True) info_font_regular = Font(name='Calibri', size=11, bold=False) header_font = Font(name='Calibri', size=11, bold=True, color='FFFFFF') header_fill = PatternFill(start_color='1F2937', end_color='1F2937', fill_type='solid') align_center = Alignment(horizontal='center', vertical='center') align_left = Alignment(horizontal='left', vertical='center') border_thin = Border( left=Side(style='thin', color='D1D5DB'), right=Side(style='thin', color='D1D5DB'), top=Side(style='thin', color='D1D5DB'), bottom=Side(style='thin', color='D1D5DB') ) # ─── FITUR LOGO PERUSAHAAN (PROPORSIONAL DI CELL A1) ─── logo_path = os.path.join(settings.BASE_DIR, 'dashboard', 'static', 'images', 'FLN.png') if os.path.exists(logo_path): img = OpenpyxlImage(logo_path) target_height = 55 target_width = int((img.width / img.height) * target_height) img.width = target_width img.height = target_height ws.add_image(img, 'A1') ws.row_dimensions[1].height = 25 ws.row_dimensions[2].height = 20 ws.row_dimensions[3].height = 15 # ─── TEKS JUDUL LAPORAN ─── ws['B1'] = "Sistem Informasi Monitoring Energi" ws['B1'].font = title_font ws['B2'] = "PT. Frina Lestari Nusantara" ws['B2'].font = company_font # ─── BLOK INFORMASI TERINTEGRASI MESIN (BARIS 5 - 7) ─── ws['A5'] = "Nama Mesin:" ws['B5'] = nama_mesin_info ws['A6'] = "Area/Lokasi:" ws['B6'] = area_info ws['A7'] = "Keterangan:" ws['B7'] = keterangan_info for r in range(5, 8): ws.cell(row=r, column=1).font = info_font_bold ws.cell(row=r, column=2).font = info_font_regular ws.cell(row=r, column=1).alignment = align_left ws.cell(row=r, column=2).alignment = align_left # ─── TABEL UTAMA DIMULAI PADA BARIS KE-9 ─── headers = ['No', f'Periode ({title_period})', 'Avg Apparent Power (VA)', 'Avg Power Factor', 'Avg Total Current (A)'] for col_num, header in enumerate(headers, 1): cell = ws.cell(row=9, column=col_num) cell.value = header cell.font = header_font cell.fill = header_fill cell.alignment = align_center # ─── ISI DATA TABEL EXCEL ─── row_num = 10 for idx, entry in enumerate(logs, 1): if entry['period_label']: date_str = entry['period_label'].strftime(date_format) row = [ idx, date_str, round(entry['avg_apparent'] or 0, 2), round(entry['avg_pf'] or 0, 3), round(entry['avg_current'] or 0, 4) ] for col_num, value in enumerate(row, 1): cell = ws.cell(row=row_num, column=col_num) cell.value = value cell.border = border_thin if col_num in (1, 2): cell.alignment = align_center row_num += 1 # Autofit lebar kolom (Bebas Eror Tuple) for col in ws.columns: max_len = max(len(str(cell.value or '')) for cell in col) col_letter = openpyxl.utils.get_column_letter(col[0].column) ws.column_dimensions[col_letter].width = max(max_len + 4, 12) # ─── COPYRIGHT PADA FOOTER CETAK DOKUMEN ─── ws.oddFooter.left.text = "Copyright System IT-Dept-2026 | PT. Frina Lestari Nusantara" ws.oddFooter.left.size = 9 ws.oddFooter.left.font = "Calibri" # Response Download Sheet response = HttpResponse( content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) filename = f"Analisis_Parameter_{period}_{timezone.now().strftime('%Y%m%d_%H%M')}.xlsx" response['Content-Disposition'] = f'attachment; filename="{filename}"' wb.save(response) return response # View untuk menampilkan halaman list mesin & daftar area def manajemen_mesin_view(request): daftar_mesin = Mesin.objects.select_related('area').all().order_by('-created_at') daftar_area = Area.objects.all().order_by('nama_area') context = { 'daftar_mesin': daftar_mesin, 'daftar_area': daftar_area } return render(request, 'dashboard/manajemen_mesin.html', context) # API untuk menyimpan data mesin baru + area + token dari popup @csrf_exempt def add_mesin_api(request): if request.method == 'POST': try: data = json.loads(request.body) nama = data.get('nama_mesin') area_id = data.get('area_id') keterangan = data.get('keterangan', '') if not nama: return JsonResponse({'status': 'error', 'message': 'Nama mesin wajib diisi'}, status=400) # Generate Token Keamanan Unik untuk ESP32 token_esp32 = str(uuid.uuid4()).replace('-', '') # Cari objek Area berdasarkan ID jika dipilih area_obj = Area.objects.filter(id=area_id).first() if area_id else None # Simpan mesin baru mesin_baru = Mesin.objects.create( nama_mesin=nama, area=area_obj, keterangan=keterangan, token=token_esp32 ) return JsonResponse({ 'status': 'success', 'message': 'Mesin berhasil ditambahkan!', 'data': { 'id': mesin_baru.id, 'nama': mesin_baru.nama_mesin, 'area': mesin_baru.area.nama_area if mesin_baru.area else '-', 'keterangan': mesin_baru.keterangan, 'token': mesin_baru.token } }) except Exception as e: return JsonResponse({'status': 'error', 'message': str(e)}, status=500) return JsonResponse({'status': 'error', 'message': 'Method tidak diizinkan'}, status=405) # API untuk menyimpan data Area baru @csrf_exempt def add_area_api(request): if request.method == 'POST': try: data = json.loads(request.body) nama_area = data.get('nama_area') keterangan = data.get('keterangan', '') if not nama_area: return JsonResponse( {'status': 'error', 'message': 'Nama area wajib diisi'}, status=400, ) area_baru = Area.objects.create( nama_area=nama_area, keterangan=keterangan ) return JsonResponse({ 'status': 'success', 'message': 'Area berhasil ditambahkan!', 'data': {'id': area_baru.id, 'nama_area': area_baru.nama_area}, }) except Exception as e: return JsonResponse( {'status': 'error', 'message': str(e)}, status=500 ) return JsonResponse( {'status': 'error', 'message': 'Method tidak diizinkan'}, status=405 ) # API untuk edit keterangan @require_POST def update_keterangan_view(request, mesin_id): try: data = json.loads(request.body) keterangan_baru = data.get('keterangan', '').strip() # Ambil data mesin berdasarkan ID mesin = get_object_or_404(Mesin, id=mesin_id) mesin.keterangan = keterangan_baru mesin.save(update_fields=['keterangan']) # Hanya simpan kolom keterangan saja agar efisien return JsonResponse({ 'status': 'success', 'keterangan': mesin.keterangan }) except Exception as e: return JsonResponse({ 'status': 'error', 'message': str(e) }, status=400) # UNtuk halaman Report def report_per_area(request): # Logika data bisa diisi di sini nanti return render(request, 'dashboard/report_per_area.html') def report_all(request): # Logika data bisa diisi di sini nanti return render(request, 'dashboard/report_all.html') # 1. View untuk Render Halaman Report Area HTML def report_per_area_view(request): # Mengambil list area untuk dropdown filter awal areas = Area.objects.all().order_by('nama_area') # SINKRONISASI DATABASE RIIL: Ambil tanggal unik berdasarkan bulan secara kronologis (Terbaru di atas) # Ini mendeteksi field 'timestamp' di KwhReadings, mengambil bulan uniknya saja, dan mencegah duplikasi bulan_db = ( KwhReadings.objects .dates('timestamp', 'month', order='DESC') ) months_list = [] for date_obj in bulan_db: months_list.append({ 'value': date_obj.strftime('%Y-%m'), # Nilai untuk sistem filter (Contoh: "2026-09") 'label': date_obj.strftime('%B %Y') # Label teks yang dibaca user (Contoh: "September 2026") }) # Pengaman jika seandainya database baru dideploy dan masih kosong total if not months_list: import datetime today = datetime.date.today() months_list.append({ 'value': today.strftime('%Y-%m'), 'label': today.strftime('%B %Y') }) context = { 'areas': areas, 'months': months_list } return render(request, 'dashboard/report_per_area.html', context) # 2. API Endpoint untuk mengambil data report via AJAX (Tetap Utuh) def api_report_per_area(request): selected_month = request.GET.get('month') # Format: "YYYY-MM" area_id = request.GET.get('area_id') if not selected_month or not area_id: return JsonResponse({'status': 'error', 'message': 'Parameter tidak lengkap'}, status=400) try: year, month = map(int, selected_month.split('-')) except ValueError: return JsonResponse({'status': 'error', 'message': 'Format bulan salah'}, status=400) # Menggunakan .exclude() menjamin data status NULL atau 'normal' tetap dihitung daftar_mesin = Mesin.objects.filter(area_id=area_id).exclude(status='review').order_by('nama_mesin') labels_mesin = [] values_kwh = [] tabel_data = [] total_akumulasi_area = 0 # 2. Hitung kwh per masing-masing mesin pada bulan tersebut for mesin in daftar_mesin: # Filter log berdasarkan mesin dan range bulan terpilih queryset = KwhReadings.objects.filter( mesin_id=mesin.id, timestamp__year=year, timestamp__month=month ) # Agregasi Max dan Min pada bulan itu agregasi = queryset.aggregate( max_val=Max('active_power_kwh'), min_val=Min('active_power_kwh') ) max_kwh = agregasi['max_val'] or 0 min_kwh = agregasi['min_val'] or 0 # Logika hitung konsumsi riil bulanan (seperti view log_kwh Anda) konsumsi_bulan = max_kwh - min_kwh if konsumsi_bulan < 0: konsumsi_bulan = max_kwh konsumsi_bulan = round(konsumsi_bulan, 4) total_akumulasi_area += konsumsi_bulan labels_mesin.append(mesin.nama_mesin) values_kwh.append(konsumsi_bulan) # Masukkan ke list untuk kebutuhan render tabel data di HTML tabel_data.append({ 'nama_mesin': mesin.nama_mesin, 'keterangan': mesin.keterangan or '-', 'min_kwh': round(min_kwh, 4), 'max_kwh': round(max_kwh, 4), 'konsumsi': konsumsi_bulan }) return JsonResponse({ 'labels': labels_mesin, 'values': values_kwh, 'tabel_data': tabel_data, 'total_sum': round(total_akumulasi_area, 4) }) # EXPORT EXCEL PER AREA def export_report_area_excel(request): # 1. Ambil parameter filter dari request URL selected_month = request.GET.get('month') # Format: "YYYY-MM" area_id = request.GET.get('area_id') if not selected_month or not area_id: return HttpResponse("Parameter tidak lengkap untuk export excel.", status=400) try: year, month = map(int, selected_month.split('-')) # Membuat label nama bulan untuk judul/info (Contoh: "September 2026") dt_obj = timezone.datetime(year, month, 1) nama_bulan_info = dt_obj.strftime('%B %Y') except ValueError: return HttpResponse("Format bulan salah.", status=400) # Ambil Informasi Area untuk Header Excel nama_area_info = "-" try: area_obj = Area.objects.get(id=area_id) nama_area_info = area_obj.nama_area except Area.DoesNotExist: return HttpResponse("Area tidak ditemukan.", status=404) # Ganti baris pengambil daftar_mesin untuk excel: daftar_mesin = Mesin.objects.filter(area_id=area_id).exclude(status='review').order_by('nama_mesin') # 3. Inisialisasi Workbook Excel & Styling (Konsisten dengan tema sebelumnya) wb = openpyxl.Workbook() ws = wb.active ws.title = "Report Per Area" # Font & Style Setup title_font = Font(name='Calibri', size=14, bold=True, color='1F2937') company_font = Font(name='Calibri', size=11, bold=True, color='4B5563') info_font_bold = Font(name='Calibri', size=11, bold=True) info_font_regular = Font(name='Calibri', size=11, bold=False) header_font = Font(name='Calibri', size=11, bold=True, color='FFFFFF') header_fill = PatternFill(start_color='1F2937', end_color='1F2937', fill_type='solid') align_center = Alignment(horizontal='center', vertical='center') align_left = Alignment(horizontal='left', vertical='center') align_right = Alignment(horizontal='right', vertical='center') border_thin = Border( left=Side(style='thin', color='D1D5DB'), right=Side(style='thin', color='D1D5DB'), top=Side(style='thin', color='D1D5DB'), bottom=Side(style='thin', color='D1D5DB') ) # ─── FITUR LOGO PERUSAHAAN DI CELL A1 ─── logo_path = os.path.join(settings.BASE_DIR, 'dashboard', 'static', 'images', 'FLN.png') if os.path.exists(logo_path): img = OpenpyxlImage(logo_path) target_height = 55 target_width = int((img.width / img.height) * target_height) img.width = target_width img.height = target_height ws.add_image(img, 'A1') ws.row_dimensions[1].height = 25 ws.row_dimensions[2].height = 20 ws.row_dimensions[3].height = 15 # ─── TEKS JUDUL KOLOM B ─── ws['B1'] = "Sistem Informasi Monitoring Energi" ws['B1'].font = title_font ws['B2'] = "PT. Frina Lestari Nusantara" ws['B2'].font = company_font # ─── BLOK INFORMASI AREA & PERIODE BARIS 5 - 6 ─── ws['A5'] = "Nama Area:" ws['B5'] = nama_area_info ws['A6'] = "Periode Bulan:" ws['B6'] = nama_bulan_info for r in range(5, 7): ws.cell(row=r, column=1).font = info_font_bold ws.cell(row=r, column=2).font = info_font_regular ws.cell(row=r, column=1).alignment = align_left ws.cell(row=r, column=2).alignment = align_left # 4. ─── TABEL UTAMA PADA BARIS KE-8 ─── headers = ['No', 'Nama Mesin', 'Keterangan', 'Min kWh Mula', 'Max kWh Akhir', 'Total Konsumsi (kWh)'] for col_num, header in enumerate(headers, 1): cell = ws.cell(row=8, column=col_num) cell.value = header cell.font = header_font cell.fill = header_fill cell.alignment = align_center # 5. ─── ISI DATA ROW MULAI DARI BARIS KE-9 ─── row_num = 9 total_akumulasi_area = 0 for idx, mesin in enumerate(daftar_mesin, 1): # Ambil data kwh_readings berdasarkan mesin dan rentang bulan terpilih queryset = KwhReadings.objects.filter( mesin_id=mesin.id, timestamp__year=year, timestamp__month=month ) # Agregasi data Min dan Max pada bulan berjalan agregasi = queryset.aggregate( max_val=Max('active_power_kwh'), min_val=Min('active_power_kwh') ) min_kwh = agregasi['min_val'] or 0 max_kwh = agregasi['max_val'] or 0 # Logika hitung konsumsi bulanan (sejalan dengan grafik & aturan log_kwh) konsumsi_bulan = max_kwh - min_kwh if konsumsi_bulan < 0: konsumsi_bulan = max_kwh total_akumulasi_area += konsumsi_bulan # Susun baris data row_data = [ idx, mesin.nama_mesin, mesin.keterangan or "-", round(min_kwh, 4), round(max_kwh, 4), round(konsumsi_bulan, 4) ] for col_num, value in enumerate(row_data, 1): cell = ws.cell(row=row_num, column=col_num) cell.value = value cell.border = border_thin # Styling perataan kolom if col_num == 1: cell.alignment = align_center elif col_num in (2, 3): cell.alignment = align_left else: cell.alignment = align_right row_num += 1 # 6. ─── SUNTIKKAN BARIS REKAP TOTAL DI PALING BAWAH TABEL EXCEL ─── # Satukan kolom A sampai E (kolom 1 sampai 5) untuk label TOTAL ws.merge_cells(start_row=row_num, start_column=1, end_row=row_num, end_column=5) cell_label = ws.cell(row=row_num, column=1) cell_label.value = f"TOTAL KONSUMSI ENERGI {nama_area_info.upper()}" cell_label.font = Font(name='Calibri', size=11, bold=True) cell_label.alignment = align_right # Isi angka total akumulasi di kolom F (kolom 6) dan beri warna background amber soft (FEF3C7) cell_total = ws.cell(row=row_num, column=6) cell_total.value = round(total_akumulasi_area, 4) cell_total.font = Font(name='Calibri', size=11, bold=True) cell_total.fill = PatternFill(start_color='FEF3C7', end_color='FEF3C7', fill_type='solid') cell_total.alignment = align_right # Terapkan border tipis untuk baris total akumulasi ini for col_idx in range(1, 7): ws.cell(row=row_num, column=col_idx).border = border_thin # Autofit lebar kolom agar tulisan tidak terpotong for col in ws.columns: max_len = max(len(str(cell.value or '')) for cell in col) col_letter = openpyxl.utils.get_column_letter(col[0].column) ws.column_dimensions[col_letter].width = max(max_len + 4, 12) # COPYRIGHT PADA FOOTER CETAK DOKUMEN (KIRI BAWAH) ws.oddFooter.left.text = "Copyright System IT-Dept-2026 | PT. Frina Lestari Nusantara" ws.oddFooter.left.size = 9 ws.oddFooter.left.font = "Calibri" # 7. Kirim Response ke Browser sebagai File Unduhan Excel response = HttpResponse( content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) filename = f"Report_Area_{nama_area_info.replace(' ', '_')}_{selected_month}.xlsx" response['Content-Disposition'] = f'attachment; filename="{filename}"' wb.save(response) return response # Halaman Report All def report_all_view(request): bulan_db = KwhReadings.objects.dates('timestamp', 'month', order='DESC') months_list = [] for date_obj in bulan_db: months_list.append({ 'value': date_obj.strftime('%Y-%m'), 'label': date_obj.strftime('%B %Y') }) if not months_list: import datetime today = datetime.date.today() months_list.append({ 'value': today.strftime('%Y-%m'), 'label': today.strftime('%B %Y') }) context = { 'months': months_list } return render(request, 'dashboard/report_all.html', context) # API Data Report All dengan Perbaikan Bug def api_report_all_data(request): selected_month = request.GET.get('month') # Format: "YYYY-MM" if not selected_month: return JsonResponse({'status': 'error', 'message': 'Bulan harus dipilih'}, status=400) try: year, month = map(int, selected_month.split('-')) except ValueError: return JsonResponse({'status': 'error', 'message': 'Format bulan salah'}, status=400) daftar_area = Area.objects.all().order_by('nama_area') labels_area = [] values_kwh = [] tabel_data = [] total_seluruh_pabrik = 0 for area in daftar_area: # 🟢 PERBAIKAN 1: Mengubah area_id menjadi area.id agar terbaca dari loop objek 'area' mesin_di_area = Mesin.objects.filter(area_id=area.id).exclude(status='review') total_kwh_area = 0 for mesin in mesin_di_area: queryset = KwhReadings.objects.filter( mesin_id=mesin.id, timestamp__year=year, timestamp__month=month ) # 🟢 PERBAIKAN 2: Fungsi Max & Min di sini dijamin aman karena sudah di-import di atas agregasi = queryset.aggregate( max_val=Max('active_power_kwh'), min_val=Min('active_power_kwh') ) max_kwh = agregasi['max_val'] or 0 min_kwh = agregasi['min_val'] or 0 konsumsi = max_kwh - min_kwh if konsumsi < 0: konsumsi = max_kwh total_kwh_area += konsumsi total_kwh_area = round(total_kwh_area, 2) total_seluruh_pabrik += total_kwh_area labels_area.append(area.nama_area) values_kwh.append(total_kwh_area) tabel_data.append({ 'nama_area': area.nama_area, 'jumlah_mesin': mesin_di_area.count(), 'total_konsumsi': total_kwh_area }) return JsonResponse({ 'labels': labels_area, 'values': values_kwh, 'tabel_data': tabel_data, 'total_sum': round(total_seluruh_pabrik, 2) }) # 📌 Pastikan dekorator ditulis rata kiri, sejajar dengan kata 'def' @login_required def dashboard_view(request): # ... isi kode dashboard Anda saat ini ... return render(request, 'dashboard/index.html') # API baru untuk mengubah status mesin (Normal / Review) dari dropdown tabel web @require_POST def update_status_view(request, mesin_id): try: data = json.loads(request.body) status_baru = data.get('status', 'normal').strip() if status_baru not in ['normal', 'review']: return JsonResponse({'status': 'error', 'message': 'Status tidak valid'}, status=400) # PERBAIKAN TOTAL: Paksa update langsung tembus ke baris tabel database asli Mesin.objects.filter(id=mesin_id).update(status=status_baru) return JsonResponse({'status': 'success', 'message': 'Status mesin berhasil diperbarui'}) except Exception as e: return JsonResponse({'status': 'error', 'message': str(e)}, status=400)