1346 lines
50 KiB
Python
1346 lines
50 KiB
Python
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)
|
|
|