From 2d9c0fc08dafe9b3ff53604378c7bfe289a91a94 Mon Sep 17 00:00:00 2001 From: Jevinca Marvella Date: Sun, 14 Dec 2025 02:07:42 +0700 Subject: [PATCH] Revisi --- detail_transaksi.csv | 1 + favorite.csv | 1 + main.py | 1096 +++++++++++++++++++++++++++++++----------- menu.csv | 2 +- pembayaran.csv | 1 + transaksi.csv | 1 + 6 files changed, 824 insertions(+), 278 deletions(-) diff --git a/detail_transaksi.csv b/detail_transaksi.csv index c07b1e8..038df96 100644 --- a/detail_transaksi.csv +++ b/detail_transaksi.csv @@ -10,3 +10,4 @@ id,transaksi_id,menu_id,qty,harga_satuan,subtotal_item 9,6,3,1,30000.0,30000.0 10,7,4,2,25000.0,50000.0 11,7,3,2,30000.0,60000.0 +12,8,11,1,18000.0,18000.0 diff --git a/favorite.csv b/favorite.csv index 8bc90f3..cf07897 100644 --- a/favorite.csv +++ b/favorite.csv @@ -6,3 +6,4 @@ user_id,menu_id,order_count,last_ordered 4,2,1,2025-12-13 22:18:23 4,4,2,2025-12-13 22:22:02 4,3,2,2025-12-13 22:22:02 +4,11,1,2025-12-14 01:44:46 diff --git a/main.py b/main.py index e2e6e2d..9db2bb5 100644 --- a/main.py +++ b/main.py @@ -537,6 +537,321 @@ def transaksi_update_status(transaksi_id, new_status): return True return False +# ======================================== +# TAMBAHAN FUNCTIONS - MEJA MODULE +# ======================================== + +def meja_get_all_with_transaksi(): + """Ambil semua meja dengan detail transaksi terkait""" + rows = read_all(MEJA_CSV) + out = [] + + for r in rows: + nomor = int(r.get("nomor_meja", 0)) + status = r.get("status", "kosong") + transaksi_id = r.get("transaksi_id", "") + + transaksi_info = None + if transaksi_id: + try: + transaksi_data = transaksi_get(int(transaksi_id)) + if transaksi_data: + tid, uid, meja_num, total, trx_status, promo, subtotal, item_disc, promo_disc, tanggal = transaksi_data + transaksi_info = { + 'id': tid, + 'total': total, + 'status': trx_status, + 'tanggal': tanggal + } + except: + pass + + out.append({ + 'nomor': nomor, + 'status': status, + 'transaksi_id': transaksi_id, + 'transaksi_info': transaksi_info + }) + + return sorted(out, key=lambda x: x['nomor']) + + +def meja_get_status_summary(): + """Dapatkan ringkasan status meja""" + all_meja = meja_get_all_with_transaksi() + + summary = { + 'total': len(all_meja), + 'kosong': sum(1 for m in all_meja if m['status'] == 'kosong'), + 'terisi': sum(1 for m in all_meja if m['status'] == 'terisi'), + 'meja_list': all_meja + } + + return summary + + +def meja_reset_all_kosong(): + """Reset semua meja menjadi kosong (untuk admin/maintenance)""" + rows = read_all(MEJA_CSV) + for r in rows: + r['status'] = 'kosong' + r['transaksi_id'] = '' + + write_all(MEJA_CSV, ["nomor_meja", "status", "transaksi_id"], rows) + return True + + +# ======================================== +# TAMBAHAN FUNCTIONS - PAYMENT MODULE +# ======================================== + +def pembayaran_list_by_date_range(start_date, end_date, status_filter=None): + """Ambil pembayaran dalam rentang tanggal""" + from datetime import datetime + + rows = read_all(PEMBAYARAN_CSV) + out = [] + + for r in rows: + try: + payment_date = datetime.strptime(r.get("tanggal_bayar"), "%Y-%m-%d %H:%M:%S") + except: + continue + + if payment_date < start_date or payment_date > end_date: + continue + + if status_filter and r.get("status_pembayaran") != status_filter: + continue + + try: + pid = int(r.get("id", 0)) + tid = int(r.get("transaksi_id", 0)) + jumlah = float(r.get("jumlah_bayar", 0.0)) + except: + continue + + out.append(( + pid, + tid, + r.get("metode_pembayaran"), + jumlah, + r.get("status_pembayaran"), + r.get("tanggal_bayar") + )) + + return sorted(out, key=lambda x: x[5], reverse=True) + + +def pembayaran_get_summary(start_date, end_date, status_filter=None, method_filter=None): + """Hitung ringkasan pembayaran - DENGAN FILTER METODE""" + + payments = pembayaran_list_by_date_range(start_date, end_date, + status_filter=status_filter) + + total_income = 0 + total_count = 0 + + metode_breakdown = {} + + for p in payments: + pid, tid, metode, jumlah, status, tanggal = p + + # FILTER BY METODE + if method_filter and method_filter != 'semua': + # Normalize metode untuk comparison + metode_clean = metode.lower().replace(' ', '') + filter_clean = method_filter.lower().replace(' ', '') + + if metode_clean != filter_clean: + continue # Skip jika tidak sesuai filter + + total_income += jumlah + total_count += 1 + + metode = p[2] + metode_breakdown[metode] = metode_breakdown.get(metode, 0) + 1 + + return { + 'total_income': total_income, + 'total_count': total_count, + 'avg_per_transaction': total_income / total_count if total_count > 0 else 0, + 'metode_breakdown': metode_breakdown, + 'payments': payments + } + + +def pembayaran_validate_cash(jumlah_bayar, total_transaksi): + """Validasi pembayaran cash""" + if jumlah_bayar < total_transaksi: + return False, f"Uang kurang! Kurang: Rp {total_transaksi - jumlah_bayar:,.0f}" + return True, "Valid" + + +def pembayaran_calculate_change(jumlah_bayar, total_transaksi): + """Hitung kembalian""" + if jumlah_bayar < total_transaksi: + return None + return jumlah_bayar - total_transaksi + + +# ======================================== +# TAMBAHAN FUNCTIONS - REPORT MODULE +# ======================================== + +def report_get_daily_summary(target_date=None, method_filter=None): + """Dapatkan ringkasan penjualan harian - DENGAN FILTER METODE""" + from datetime import datetime + + if target_date is None: + target_date = datetime.now().date() + + start_datetime = datetime.combine(target_date, datetime.min.time()) + end_datetime = datetime.combine(target_date, datetime.max.time()) + + # PASS METHOD_FILTER KE FUNCTION INI + summary = pembayaran_get_summary( + start_datetime, + end_datetime, + status_filter='sukses', + method_filter=method_filter # ← TAMBAH INI + ) + + return { + 'tanggal': target_date.strftime("%Y-%m-%d"), + 'total_transaksi': summary['total_count'], + 'total_pendapatan': summary['total_income'], + 'rata_rata': summary['avg_per_transaction'], + 'metode_breakdown': summary['metode_breakdown'], + 'details': summary['payments'] + } + + +def report_get_weekly_summary(week_start=None, method_filter=None): + """Dapatkan ringkasan penjualan mingguan - DENGAN FILTER METODE""" + from datetime import datetime, timedelta + + if week_start is None: + today = datetime.now() + week_start = today - timedelta(days=today.weekday()) + + week_start_dt = datetime.combine(week_start.date(), datetime.min.time()) + week_end_dt = week_start_dt + timedelta(days=7) + + # PASS METHOD_FILTER + summary = pembayaran_get_summary( + week_start_dt, + week_end_dt, + status_filter='sukses', + method_filter=method_filter # ← TAMBAH INI + ) + + return { + 'minggu_mulai': week_start.strftime("%Y-%m-%d"), + 'total_transaksi': summary['total_count'], + 'total_pendapatan': summary['total_income'], + 'rata_rata': summary['avg_per_transaction'], + 'metode_breakdown': summary['metode_breakdown'] + } + + +def report_get_monthly_summary(year=None, month=None, method_filter=None): + """Dapatkan ringkasan penjualan bulanan - DENGAN FILTER METODE""" + from datetime import datetime, date + import calendar + + if year is None: + year = datetime.now().year + if month is None: + month = datetime.now().month + + first_day = date(year, month, 1) + last_day = date(year, month, calendar.monthrange(year, month)[1]) + + start_datetime = datetime.combine(first_day, datetime.min.time()) + end_datetime = datetime.combine(last_day, datetime.max.time()) + + # PASS METHOD_FILTER + summary = pembayaran_get_summary( + start_datetime, + end_datetime, + status_filter='sukses', + method_filter=method_filter # ← TAMBAH INI + ) + + return { + 'bulan': f"{first_day.strftime('%B %Y')}", + 'total_transaksi': summary['total_count'], + 'total_pendapatan': summary['total_income'], + 'rata_rata': summary['avg_per_transaction'], + 'metode_breakdown': summary['metode_breakdown'] + } + + +def report_export_to_text(report_data, report_type='daily'): + """Export laporan ke format text""" + text = "=" * 70 + "\n" + text += "LAPORAN PENJUALAN CAFE TOTORO MANIA\n" + text += "=" * 70 + "\n\n" + + if report_type == 'daily': + text += f"PERIODE: {report_data['tanggal']}\n" + text += f"TIPE: Laporan Harian\n" + elif report_type == 'weekly': + text += f"PERIODE: Minggu dimulai {report_data['minggu_mulai']}\n" + text += f"TIPE: Laporan Mingguan\n" + else: + text += f"PERIODE: {report_data.get('bulan', 'Unknown')}\n" + text += f"TIPE: Laporan Bulanan\n" + + text += "=" * 70 + "\n\n" + + text += "RINGKASAN PENJUALAN:\n" + text += "-" * 70 + "\n" + text += f"Total Transaksi : {report_data['total_transaksi']} transaksi\n" + text += f"Total Pendapatan : Rp {report_data['total_pendapatan']:,.2f}\n" + text += f"Rata-rata : Rp {report_data['rata_rata']:,.2f} per transaksi\n" + text += "\n" + + text += "BREAKDOWN METODE PEMBAYARAN:\n" + text += "-" * 70 + "\n" + for metode, count in sorted(report_data['metode_breakdown'].items()): + percentage = (count / report_data['total_transaksi'] * 100) if report_data['total_transaksi'] > 0 else 0 + text += f"{metode.upper().ljust(20)} : {str(count).rjust(3)} transaksi ({percentage:.1f}%)\n" + + text += "=" * 70 + "\n" + + return text + + +# ======================================== +# HELPER FUNCTIONS - FORMATTING +# ======================================== + +def format_currency(amount): + """Format nilai menjadi Rupiah""" + return f"Rp {amount:,.0f}" + + +def format_datetime(dt_string): + """Format datetime string menjadi readable""" + try: + from datetime import datetime + dt = datetime.strptime(dt_string, "%Y-%m-%d %H:%M:%S") + return dt.strftime("%d/%m/%Y %H:%M") + except: + return dt_string + + +def validate_meja_number(nomor_meja): + """Validasi nomor meja""" + try: + nomor = int(nomor_meja) + if nomor < 1 or nomor > 99: + return False, "Nomor meja harus 1-99" + return True, "Valid" + except: + return False, "Nomor meja harus berupa angka" def detail_transaksi_list(transaksi_id): """Ambil semua detail item dari transaksi tertentu""" @@ -762,6 +1077,20 @@ def meja_tutup(nomor_meja): """Tutup meja (set ke kosong)""" return meja_update_status(nomor_meja, "kosong", "") +def meja_get(nomor_meja): + """Ambil data meja berdasarkan nomor""" + rows = read_all(MEJA_CSV) + for r in rows: + if r.get("nomor_meja") == str(nomor_meja): + try: + nomor = int(r.get("nomor_meja") or 0) + except: + nomor = r.get("nomor_meja") + + transaksi_id = r.get("transaksi_id") or "" + return (nomor, r.get("status"), transaksi_id) + return None + # Wilayah dikuasai UI @@ -863,7 +1192,6 @@ class App: text=header_text, font=("Arial", 12) ).pack(side='left', padx=10, pady=6) - ttk.Button(top, text="Logout", command=self.logout).pack(side='right', padx=10) main = ttk.Notebook(self.root) main.pack(fill='both', expand=True, padx=10, pady=8) @@ -2697,8 +3025,17 @@ class App: ttk.Label(filter_frame, text="Metode:").grid(row=1, column=0, sticky='w', padx=5, pady=4) self.report_method_var = tk.StringVar(value='semua') + method_combo = ttk.Combobox(filter_frame, textvariable=self.report_method_var, width=20, state='readonly') - method_combo['values'] = ('Semua', 'Cash', 'QRIS', 'E-Wallet') + method_combo['values'] = ( + 'Semua', + 'Cash', + 'Qris', + 'Ewallet-gopay', + 'Ewallet-ovo', + 'Ewallet-dana', + 'Ewallet-shopeepay' +) method_combo.grid(row=1, column=1, sticky='w', padx=5, pady=4) # Tombol generate @@ -2708,6 +3045,12 @@ class App: command=self.generate_report, style="Accent.TButton" ).grid(row=2, column=0, columnspan=2, pady=10) + ttk.Button( + filter_frame, + text="šŸ’¾ Export Laporan", + command=self.export_report_to_file, + style="Accent.TButton" + ).grid(row=3, column=0, pady=10) # Summary frame summary_frame = ttk.LabelFrame(parent, text="šŸ“ˆ Ringkasan", padding=10) @@ -3010,85 +3353,88 @@ class App: def generate_report(self): - """Generate laporan berdasarkan filter""" - from datetime import datetime, timedelta + """Generate laporan dengan data terstruktur""" + from datetime import datetime - # Clear tree + # Clear tree lama for r in self.report_tree.get_children(): self.report_tree.delete(r) - # Get filter period = self.report_period_var.get() method_filter = self.report_method_var.get().lower() - # Hitung tanggal range - today = datetime.now() + try: + # GUNAKAN HELPER FUNCTIONS + if period == 'harian': + report_data = report_get_daily_summary(method_filter=method_filter) + elif period == 'mingguan': + report_data = report_get_weekly_summary(method_filter=method_filter) + else: # bulanan + report_data = report_get_monthly_summary(method_filter=method_filter) + except Exception as e: + messagebox.showerror("Error", f"Gagal generate laporan: {e}") + return - if period == 'harian': - start_date = today.replace(hour=0, minute=0, second=0, microsecond=0) - elif period == 'mingguan': - start_date = today - timedelta(days=today.weekday()) - start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) - else: # bulanan - start_date = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + # Update summary labels dari report_data (BUKAN manual count!) + self.report_total_trx_label.config( + text=str(report_data['total_transaksi']) + ) + self.report_total_income_label.config( + text=format_currency(report_data['total_pendapatan']) + ) + self.report_avg_label.config( + text=format_currency(report_data['rata_rata']) + ) - # Get data transaksi yang sudah dibayar - all_transaksi = transaksi_list(status='dibayar') + # Populate tree dari payment details + for payment in report_data.get('details', []): + pid, tid, metode, jumlah, status, tanggal = payment + self.report_tree.insert("", tk.END, values=( + tid, + format_datetime(tanggal), + "-", # meja (not available in payment data) + format_currency(jumlah), + metode.upper() if metode else "-", + status + )) + + + def export_report_to_file(self): + """Export laporan ke file text - FUNGSI BARU""" + from datetime import datetime - filtered_transaksi = [] - total_income = 0 + period = self.report_period_var.get() - for trx in all_transaksi: - tid, uid, meja, total, status, promo_code, tanggal = trx - - # Parse tanggal - try: - trx_date = datetime.strptime(tanggal, "%Y-%m-%d %H:%M:%S") - except: - continue - - # Filter by date range - if trx_date < start_date: - continue - - # Get payment info - payment_data = pembayaran_get_by_transaksi(tid) - if not payment_data: - continue - - pid, metode, jumlah, status_bayar, tanggal_bayar, struk = payment_data - - # Filter by method - if method_filter != 'semua': - if method_filter == 'cash' and metode != 'cash': - continue - elif method_filter == 'qris' and metode != 'qris': - continue - elif method_filter == 'e-wallet' and not metode.startswith('ewallet'): - continue - - # Add to filtered - filtered_transaksi.append((tid, tanggal, meja, total, metode, status)) - total_income += total - - # Insert to tree - self.report_tree.insert( - "", - tk.END, - values=(tid, tanggal, meja, f"Rp {total:,.0f}", metode.upper(), status) + try: + # Get report data + if period == 'harian': + report_data = report_get_daily_summary() + elif period == 'mingguan': + report_data = report_get_weekly_summary() + else: + report_data = report_get_monthly_summary() + except: + messagebox.showerror("Error", "Belum ada data laporan. Generate terlebih dahulu!") + return + + # Export ke text + text = report_export_to_text(report_data, report_type=period) + + # Save ke file + filename = f"LAPORAN_{period}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + try: + with open(filename, 'w', encoding='utf-8') as f: + f.write(text) + messagebox.showinfo( + "āœ… Berhasil", + f"Laporan berhasil disimpan:\n{filename}\n\nBuka file untuk melihat detail lengkap." ) - - # Update summary - count = len(filtered_transaksi) - avg = total_income / count if count > 0 else 0 - - self.report_total_trx_label.config(text=str(count)) - self.report_total_income_label.config(text=f"Rp {total_income:,.0f}") - self.report_avg_label.config(text=f"Rp {avg:,.0f}") - + except Exception as e: + messagebox.showerror("āŒ Error", f"Gagal menyimpan laporan: {e}") + def show_report_chart(self): - """Tampilkan grafik laporan (dengan fallback jika matplotlib tidak ada)""" + """Tampilkan grafik laporan dengan breakdown metode + status""" try: import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg @@ -3099,8 +3445,9 @@ class App: messagebox.showwarning("Tidak Ada Data", "Generate laporan terlebih dahulu") return - # Collect data + # Collect data untuk breakdown metode_counts = {} + status_counts = {} # ← TAMBAH INI (NEW) total_per_day = {} for item_id in self.report_tree.get_children(): @@ -3113,6 +3460,10 @@ class App: # Count by metode metode_counts[metode] = metode_counts.get(metode, 0) + 1 + # NEW: Count by status pembayaran + status_key = status.upper() + status_counts[status_key] = status_counts.get(status_key, 0) + 1 + # Sum by date try: date_obj = datetime.strptime(tanggal, "%Y-%m-%d %H:%M:%S") @@ -3124,21 +3475,43 @@ class App: # Create window chart_window = tk.Toplevel(self.root) chart_window.title("šŸ“Š Grafik Laporan Penjualan") - chart_window.geometry("900x600") + chart_window.geometry("1000x650") - # Create figure with 2 subplots - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) + # Create figure with 3 subplots (atau 2 baris x 2 kolom) + fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(14, 10)) + fig.suptitle('šŸ“Š LAPORAN PENJUALAN - DASHBOARD GRAFIK', fontsize=16, fontweight='bold') - # Chart 1: Pie chart metode pembayaran + # ===== CHART 1: Pie chart metode pembayaran (KIRI ATAS) ===== if metode_counts: labels = list(metode_counts.keys()) sizes = list(metode_counts.values()) colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8'] + explode = (0.05,) * len(labels) # Sedikit separation - ax1.pie(sizes, labels=labels, autopct='%1.1f%%', colors=colors, startangle=90) - ax1.set_title('Transaksi per Metode Pembayaran', fontsize=12, fontweight='bold') + ax1.pie(sizes, labels=labels, autopct='%1.1f%%', colors=colors, + startangle=90, explode=explode, shadow=True) + ax1.set_title('šŸ’³ Transaksi per Metode Pembayaran', + fontsize=12, fontweight='bold', pad=20) - # Chart 2: Bar chart pendapatan per hari + # ===== CHART 2: Pie chart status pembayaran (KANAN ATAS) - NEW! ===== + if status_counts: + labels_status = list(status_counts.keys()) + sizes_status = list(status_counts.values()) + # Warna per status + color_map = { + 'SUKSES': '#4CAF50', + 'GAGAL': '#F44336', + 'PENDING': '#FFC107', + 'DIBAYAR': '#2196F3' + } + colors_status = [color_map.get(label, '#757575') for label in labels_status] + + ax2.pie(sizes_status, labels=labels_status, autopct='%1.1f%%', + colors=colors_status, startangle=90, shadow=True) + ax2.set_title('āœ“ Breakdown Status Pembayaran', + fontsize=12, fontweight='bold', pad=20) + + # ===== CHART 3: Bar chart pendapatan per hari (KIRI BAWAH) ===== if total_per_day: dates = sorted(total_per_day.keys()) totals = [total_per_day[d] for d in dates] @@ -3148,15 +3521,49 @@ class App: for d in dates ] - ax2.bar(date_labels, totals, color='#4ECDC4') - ax2.set_xlabel('Tanggal', fontweight='bold') - ax2.set_ylabel('Pendapatan (Rp)', fontweight='bold') - ax2.set_title('Pendapatan Harian', fontsize=12, fontweight='bold') - ax2.tick_params(axis='x', rotation=45) + bars = ax3.bar(date_labels, totals, color='#4ECDC4', edgecolor='#00796B', linewidth=1.5) - ax2.yaxis.set_major_formatter( + # Add value labels on top of bars + for bar in bars: + height = bar.get_height() + ax3.text(bar.get_x() + bar.get_width()/2., height, + f'Rp{height/1000:.0f}K', + ha='center', va='bottom', fontsize=9) + + ax3.set_xlabel('Tanggal', fontweight='bold') + ax3.set_ylabel('Pendapatan (Rp)', fontweight='bold') + ax3.set_title('šŸ’° Pendapatan Harian', fontsize=12, fontweight='bold', pad=20) + ax3.tick_params(axis='x', rotation=45) + ax3.yaxis.set_major_formatter( plt.FuncFormatter(lambda x, p: f'Rp {x/1000:.0f}K') ) + ax3.grid(axis='y', alpha=0.3, linestyle='--') + + # ===== CHART 4: Kombinasi metode + status (KANAN BAWAH) - NEW! ===== + # Buat tabel summary + ax4.axis('off') + + summary_text = "šŸ“‹ RINGKASAN TRANSAKSI\n" + summary_text += "═" * 40 + "\n\n" + + summary_text += "šŸ’³ METODE PEMBAYARAN:\n" + summary_text += "─" * 40 + "\n" + for metode, count in sorted(metode_counts.items()): + pct = (count / sum(metode_counts.values()) * 100) if metode_counts else 0 + summary_text += f" {metode:.<25} {count:>3} ({pct:>5.1f}%)\n" + + summary_text += "\nāœ“ STATUS PEMBAYARAN:\n" + summary_text += "─" * 40 + "\n" + for status, count in sorted(status_counts.items()): + pct = (count / sum(status_counts.values()) * 100) if status_counts else 0 + summary_text += f" {status:.<25} {count:>3} ({pct:>5.1f}%)\n" + + summary_text += "\n" + "═" * 40 + "\n" + summary_text += f"TOTAL TRANSAKSI: {sum(metode_counts.values())}\n" + + ax4.text(0.05, 0.95, summary_text, transform=ax4.transAxes, + fontsize=9, verticalalignment='top', fontfamily='monospace', + bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.3)) plt.tight_layout() @@ -3172,6 +3579,7 @@ class App: self.show_text_chart() + def reload_payment_orders(self): """Load transaksi dengan status 'selesai' yang belum dibayar""" # Clear tree @@ -3385,6 +3793,24 @@ class App: tid, uid, meja, total, status, promo_code, subtotal, item_disc, promo_disc, tanggal = transaksi_data + # VALIDASI 1: Status harus 'selesai' + if status != 'selesai': + messagebox.showerror("Status Error", + f"āŒ Status transaksi harus 'SELESAI'\n\n" + f"Status saat ini: {status.upper()}\n\n" + f"Silakan tunggu pesanan selesai disiapkan dulu.") + return + + # VALIDASI 2: Cek belum dibayar sebelumnya + existing_payment = pembayaran_get_by_transaksi(transaksi_id) + if existing_payment: + messagebox.showwarning("Sudah Dibayar", + f"āš ļø Transaksi ini sudah dibayar sebelumnya!\n\n" + f"Metode: {existing_payment[1].upper()}\n" + f"Tanggal: {existing_payment[4]}") + return + + # Get metode pembayaran method = self.payment_method_var.get() @@ -3423,20 +3849,26 @@ class App: messagebox.showerror("Pembayaran Gagal", message) def process_cash_payment(self, transaksi_id, total): - """Proses pembayaran cash""" - try: - cash_input = float(self.cash_amount_var.get() or 0) - except: - return False, "Jumlah bayar tidak valid" - - if cash_input < total: - return False, f"Uang kurang! Total: Rp {total:,.0f}, Bayar: Rp {cash_input:,.0f}" - - # Simpan pembayaran - pembayaran_add(transaksi_id, 'cash', cash_input, 'sukses', '') - - return True, "Pembayaran cash berhasil" + """Proses pembayaran cash - VERSI BARU""" + try: + cash_input = float(self.cash_amount_var.get() or 0) + except: + return False, "Jumlah bayar tidak valid (harus angka)" + # GUNAKAN HELPER FUNCTION untuk validasi + is_valid, msg = pembayaran_validate_cash(cash_input, total) + if not is_valid: + return False, msg + + # Hitung kembalian + change = pembayaran_calculate_change(cash_input, total) + + # Simpan pembayaran + pembayaran_add(transaksi_id, 'cash', cash_input, 'sukses', '') + + return True, f"āœ… Pembayaran cash berhasil. Kembalian: {format_currency(change)}" + + def process_qris_payment(self, transaksi_id, total): """Proses pembayaran QRIS (simulasi)""" # Konfirmasi @@ -3473,77 +3905,95 @@ class App: return True, f"Pembayaran {ewallet_name} berhasil" def generate_struk(self, transaksi_id): - """Generate struk transaksi""" - transaksi_data = transaksi_get(transaksi_id) - if not transaksi_data: - return "" - - tid, uid, meja, total, status, promo_code, subtotal, item_disc, promo_disc, tanggal = transaksi_data - detail_items = detail_transaksi_list(transaksi_id) - payment_data = pembayaran_get_by_transaksi(transaksi_id) - - struk = "═════════════════════════════════════════\n" - struk += " CAFE TOTORO MANIA\n" - struk += " Jl. Raya Kampus No. 123, Surabaya\n" - struk += " Telp: 031-123456\n" - struk += "═════════════════════════════════════════\n\n" - struk += f"No. Transaksi : {tid}\n" - struk += f"Tanggal : {tanggal}\n" - struk += f"Meja : {meja}\n" - - # Cari nama pembeli (dari user_id transaksi) - users = read_all(USERS_CSV) - customer_name = "Guest" - for u in users: - if u.get('id') == str(uid): - customer_name = u.get('username') - break - - struk += f"Pelanggan : {customer_name}\n" - struk += f"Kasir : {self.session['username']}\n" - - if payment_data: - metode = payment_data[1] - struk += f"Pembayaran : {metode.upper()}\n" - - struk += "─────────────────────────────────────────\n" - struk += "ITEM PESANAN:\n" - struk += "─────────────────────────────────────────\n" - - for detail in detail_items: - did, mid, qty, harga, subtotal_item = detail - menu_data = menu_get(mid) - if menu_data: - _, nama, kategori, _, _, _, _, _ = menu_data - struk += f"{nama}\n" - struk += f" {qty} x Rp {harga:,.0f}".ljust(30) - struk += f"Rp {subtotal_item:,.0f}\n" - - struk += "─────────────────────────────────────────\n" - struk += f"Subtotal : Rp {subtotal:,.0f}\n" - - if item_disc > 0: - struk += f"Diskon Item : Rp {item_disc:,.0f}\n" - - if promo_disc > 0: - struk += f"Diskon Promo : Rp {promo_disc:,.0f}\n" - - struk += "─────────────────────────────────────────\n" - struk += f"TOTAL BAYAR : Rp {total:,.0f}\n" - - # Jika cash, tampilkan bayar & kembalian - if payment_data and payment_data[1] == 'cash': - jumlah_bayar = payment_data[2] - kembalian = jumlah_bayar - total - struk += f"Bayar : Rp {jumlah_bayar:,.0f}\n" - struk += f"Kembalian : Rp {kembalian:,.0f}\n" - - struk += "═════════════════════════════════════════\n" - struk += " TERIMA KASIH ATAS KUNJUNGAN ANDA\n" - struk += " SAMPAI JUMPA LAGI!\n" - struk += "═════════════════════════════════════════\n" - - return struk + """Generate struk dengan format professional - VERSI BARU""" + from datetime import datetime + + transaksi_data = transaksi_get(transaksi_id) + if not transaksi_data: + return "" + + tid, uid, meja, total, status, promo_code, subtotal, item_disc, promo_disc, tanggal = transaksi_data + detail_items = detail_transaksi_list(transaksi_id) + payment_data = pembayaran_get_by_transaksi(transaksi_id) + + # Build struk string + struk = "═════════════════════════════════════════\n" + struk += " CAFE TOTORO MANIA\n" + struk += " Jl. Raya Kampus No. 123, Surabaya\n" + struk += " Telp: 031-123456\n" + struk += "═════════════════════════════════════════\n\n" + struk += f"No. Struk : STR-{tid}-{datetime.now().strftime('%Y%m%d')}\n" + struk += f"Tanggal : {format_datetime(tanggal)}\n" + struk += f"Meja : {meja}\n" + struk += f"Kasir : {self.session['username']}\n" + + if payment_data: + metode = payment_data[1] + struk += f"Pembayaran : {metode.upper()}\n" + + struk += "─────────────────────────────────────────\n" + struk += "ITEM PESANAN:\n" + struk += "─────────────────────────────────────────\n" + + for detail in detail_items: + did, mid, qty, harga, subtotal_item = detail + menu_data = menu_get(mid) + if menu_data: + _, nama, kategori, _, _, _, _, _ = menu_data + struk += f"{nama}\n" + struk += f" {qty} x {format_currency(harga)}".ljust(30) + struk += f"{format_currency(subtotal_item)}\n" + + struk += "─────────────────────────────────────────\n" + struk += f"Subtotal : {format_currency(subtotal)}\n" + + if item_disc > 0: + struk += f"Diskon Item : {format_currency(item_disc)}\n" + + if promo_disc > 0: + struk += f"Diskon Promo : {format_currency(promo_disc)}\n" + + struk += "─────────────────────────────────────────\n" + struk += f"TOTAL BAYAR : {format_currency(total)}\n" + + # Info pembayaran jika cash + if payment_data and payment_data[1] == 'cash': + jumlah_bayar = payment_data[2] + kembalian = jumlah_bayar - total + struk += f"Bayar : {format_currency(jumlah_bayar)}\n" + struk += f"Kembalian : {format_currency(kembalian)}\n" + + struk += "═════════════════════════════════════════\n" + struk += " TERIMA KASIH ATAS KUNJUNGAN ANDA\n" + struk += " SAMPAI JUMPA LAGI!\n" + struk += "═════════════════════════════════════════\n" + + # SIMPAN KE FILE untuk audit trail + self.save_struk_to_file(transaksi_id, struk) + + return struk + + + def save_struk_to_file(self, transaksi_id, struk_content): + """Simpan struk ke file - FUNGSI BARU""" + from datetime import datetime + import os + + # Buat folder 'struk' jika belum ada + if not os.path.exists('struk'): + os.makedirs('struk') + + # Format nama file dengan timestamp + filename = f"struk/STR-{transaksi_id}-{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + + try: + with open(filename, 'w', encoding='utf-8') as f: + f.write(struk_content) + return True + except Exception as e: + print(f"āš ļø Warning: Gagal save struk ke file: {e}") + return False + def show_struk(self, struk): """Tampilkan struk dalam popup window""" @@ -3670,6 +4120,7 @@ class App: # Collect data metode_counts = {} + status_counts = {} # ← TAMBAH INI (NEW) for item_id in self.report_tree.get_children(): values = self.report_tree.item(item_id)['values'] @@ -3677,39 +4128,89 @@ class App: # Count by metode metode_counts[metode] = metode_counts.get(metode, 0) + 1 + + # NEW: Count by status + status_key = status.upper() + status_counts[status_key] = status_counts.get(status_key, 0) + 1 - # Create ASCII bar chart - chart_text = "=" * 60 + "\n" - chart_text += "GRAFIK TRANSAKSI PER METODE PEMBAYARAN\n" - chart_text += "=" * 60 + "\n\n" + # ===== BUAT ASCII CHART METODE ===== + chart_text = "=" * 70 + "\n" + chart_text += "šŸ“Š GRAFIK PENJUALAN - TEXT MODE (Matplotlib tidak terinstall)\n" + chart_text += "=" * 70 + "\n\n" + + chart_text += "šŸ’³ GRAFIK TRANSAKSI PER METODE PEMBAYARAN\n" + chart_text += "─" * 70 + "\n" if metode_counts: max_count = max(metode_counts.values()) for metode, count in sorted(metode_counts.items()): - # Calculate bar length (max 40 chars) - bar_length = int((count / max_count) * 40) if max_count > 0 else 0 + # Calculate bar length (max 50 chars) + bar_length = int((count / max_count) * 50) if max_count > 0 else 0 bar = "ā–ˆ" * bar_length percentage = (count / sum(metode_counts.values())) * 100 if sum(metode_counts.values()) > 0 else 0 - chart_text += f"{metode.ljust(15)} | {bar} {count} ({percentage:.1f}%)\n" + # Format: METODE | ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ COUNT (PCT%) + chart_text += f"{metode.ljust(15)} | {bar:<50} {count:>3} ({percentage:>5.1f}%)\n" - chart_text += "\n" + "=" * 60 + "\n" - chart_text += f"Total Transaksi: {sum(metode_counts.values())}\n" - chart_text += "=" * 60 + chart_text += "\n" + "=" * 70 + "\n\n" + + # ===== BUAT ASCII CHART STATUS (NEW!) ===== + chart_text += "āœ“ GRAFIK STATUS PEMBAYARAN\n" + chart_text += "─" * 70 + "\n" + + if status_counts: + max_count_status = max(status_counts.values()) + + for status, count in sorted(status_counts.items()): + # Calculate bar length (max 50 chars) + bar_length = int((count / max_count_status) * 50) if max_count_status > 0 else 0 + + # Icon per status + icon_map = { + 'SUKSES': 'āœ“', + 'GAGAL': 'āœ—', + 'PENDING': 'ā³', + 'DIBAYAR': 'āœ”' + } + icon = icon_map.get(status, '•') + + # Warna ASCII (menggunakan symbols) + if status == 'SUKSES': + bar = "āœ“" * bar_length + elif status == 'GAGAL': + bar = "āœ—" * bar_length + elif status == 'PENDING': + bar = "ā³" * bar_length + else: + bar = "ā–ˆ" * bar_length + + percentage = (count / sum(status_counts.values())) * 100 if sum(status_counts.values()) > 0 else 0 + + # Format + chart_text += f"{icon} {status.ljust(12)} | {bar:<50} {count:>3} ({percentage:>5.1f}%)\n" + + chart_text += "\n" + "=" * 70 + "\n" + chart_text += f"šŸ“Œ TOTAL TRANSAKSI: {sum(metode_counts.values())}\n" + chart_text += f"āœ“ SUKSES: {status_counts.get('SUKSES', 0)}\n" + chart_text += f"āœ— GAGAL: {status_counts.get('GAGAL', 0)}\n" + chart_text += f"ā³ PENDING: {status_counts.get('PENDING', 0)}\n" + chart_text += f"āœ” DIBAYAR: {status_counts.get('DIBAYAR', 0)}\n" + chart_text += "=" * 70 # Show in window w = tk.Toplevel(self.root) w.title("šŸ“Š Grafik Laporan (Text Mode)") - w.geometry("700x500") + w.geometry("800x650") frm = ttk.Frame(w, padding=15) frm.pack(fill='both', expand=True) - ttk.Label(frm, text="ā„¹ļø Matplotlib tidak terinstall - Mode Text Chart", font=("Arial", 10), foreground='orange').pack(pady=10) + ttk.Label(frm, text="ā„¹ļø Matplotlib tidak terinstall - Mode Text Chart", + font=("Arial", 10), foreground='orange').pack(pady=10) - text = tk.Text(frm, width=80, height=25, font=("Courier New", 10)) + text = tk.Text(frm, width=100, height=32, font=("Courier New", 9)) text_scroll = ttk.Scrollbar(frm, orient='vertical', command=text.yview) text.configure(yscrollcommand=text_scroll.set) @@ -3719,11 +4220,24 @@ class App: text.pack(side='left', fill='both', expand=True) text_scroll.pack(side='right', fill='y') - ttk.Button(w, text="Tutup", command=w.destroy).pack(pady=10) - - - - + # Tombol + btn_frame = ttk.Frame(w) + btn_frame.pack(pady=10) + ttk.Button(btn_frame, text="šŸ’¾ Save ke File", command=lambda: self.save_text_chart(chart_text)).pack(side='left', padx=5) + ttk.Button(btn_frame, text="Tutup", command=w.destroy).pack(side='left', padx=5) + + + def save_text_chart(self, chart_text): + """Helper untuk save text chart ke file""" + from datetime import datetime + + filename = f"GRAFIK_TEXT_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + try: + with open(filename, 'w', encoding='utf-8') as f: + f.write(chart_text) + messagebox.showinfo("āœ… Berhasil", f"Grafik tersimpan:\n{filename}") + except Exception as e: + messagebox.showerror("Error", f"Gagal save: {e}") @@ -3731,7 +4245,7 @@ class App: # MEJA def build_meja_tab(self, parent): - """Tab untuk kelola status meja (admin/kasir/waiter)""" + """Tab untuk kelola status meja - VERSI BARU""" for w in parent.winfo_children(): w.destroy() @@ -3741,8 +4255,8 @@ class App: ttk.Label(header, text="šŸŖ‘ Manajemen Meja Cafe", font=("Arial", 14, "bold")).pack(side='left') ttk.Button(header, text="šŸ”„ Refresh", command=self.reload_meja_status).pack(side='right', padx=6) - # Info panel - info_frame = ttk.LabelFrame(parent, text="ā„¹ļø Info Meja", padding=10) + # Info panel - DENGAN OCCUPANCY + info_frame = ttk.LabelFrame(parent, text="šŸ“Š Info Meja", padding=10) info_frame.pack(fill='x', padx=10, pady=6) info_inner = ttk.Frame(info_frame) @@ -3756,11 +4270,14 @@ class App: self.meja_terisi_label = ttk.Label(info_inner, text="0", font=("Arial", 10, "bold"), foreground='red') self.meja_terisi_label.grid(row=0, column=3, padx=5, pady=3) - # Container untuk card meja + ttk.Label(info_inner, text="šŸ“ˆ Okupansi:", font=("Arial", 10)).grid(row=0, column=4, padx=15, pady=3) + self.meja_occupancy_label = ttk.Label(info_inner, text="0%", font=("Arial", 10, "bold")) + self.meja_occupancy_label.grid(row=0, column=5, padx=5, pady=3) + + # Canvas untuk meja cards canvas_frame = ttk.Frame(parent) canvas_frame.pack(fill='both', expand=True, padx=10, pady=6) - # Canvas dengan scrollbar canvas = tk.Canvas(canvas_frame, bg='#f5f5f5', highlightthickness=0) scrollbar = ttk.Scrollbar(canvas_frame, orient="vertical", command=canvas.yview) @@ -3777,52 +4294,64 @@ class App: canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") - # Mouse wheel scroll def _on_mousewheel(event): canvas.yview_scroll(int(-1*(event.delta/120)), "units") canvas.bind_all("", _on_mousewheel) - # Load meja cards self.reload_meja_status() - - + + def reload_meja_status(self): - """Load semua meja dalam bentuk cards""" - # Clear existing cards + """Load semua meja dalam bentuk cards - VERSI BARU""" + # Clear cards lama for widget in self.meja_cards_frame.winfo_children(): widget.destroy() - # Get all meja data - meja_list = read_all(MEJA_CSV) + # GUNAKAN HELPER FUNCTION - Ini yang baru! + summary = meja_get_status_summary() - # Hitung statistik - kosong_count = sum(1 for m in meja_list if m.get('status') == 'kosong') - terisi_count = sum(1 for m in meja_list if m.get('status') == 'terisi') + # Update info labels dari summary + self.meja_kosong_label.config(text=str(summary['kosong'])) + self.meja_terisi_label.config(text=str(summary['terisi'])) - # Update info labels - self.meja_kosong_label.config(text=str(kosong_count)) - self.meja_terisi_label.config(text=str(terisi_count)) + # Hitung & display occupancy percentage + occupancy = (summary['terisi'] / summary['total'] * 100) if summary['total'] > 0 else 0 + self.meja_occupancy_label.config(text=f"{occupancy:.0f}%") - # Render meja cards (5 kolom) + # Render meja cards (5 per row) row = 0 col = 0 - for meja in sorted(meja_list, key=lambda x: int(x.get('nomor_meja', 0))): - nomor = meja.get('nomor_meja') - status = meja.get('status', 'kosong') - transaksi_id = meja.get('transaksi_id', '') + for meja in summary['meja_list']: + nomor = meja['nomor'] + status = meja['status'] + transaksi_info = meja['transaksi_info'] - # Determine color + # Tentukan warna berdasarkan status if status == 'kosong': - bg_color = '#C8E6C9' # Light green + bg_color = '#C8E6C9' status_color = '#4CAF50' status_text = '🟢 KOSONG' + elif transaksi_info: + trx_status = transaksi_info['status'] + if trx_status == 'selesai': + bg_color = '#FFCDD2' + status_color = '#F44336' + status_text = 'šŸ”“ SELESAI' + elif trx_status == 'dibayar': + bg_color = '#BBDEFB' + status_color = '#1976D2' + status_text = 'āœ”ļø DIBAYAR' + else: + bg_color = '#FFF9C4' + status_color = '#FBC02D' + status_text = '🟔 TERISI' else: - bg_color = '#FFCDD2' # Light red - status_color = '#F44336' - status_text = 'šŸ”“ TERISI' + bg_color = '#E0E0E0' + status_color = '#757575' + status_text = '⚪ UNKNOWN' - # Create card + # BUAT CARD card = tk.Frame( self.meja_cards_frame, relief='solid', @@ -3850,65 +4379,56 @@ class App: bg=bg_color ).pack(pady=5) - # Info transaksi (jika terisi) - if status == 'terisi' and transaksi_id: + # Detail transaksi jika ada + if transaksi_info: tk.Label( card, - text=f"Transaksi: #{transaksi_id}", + text=f"Transaksi: #{transaksi_info['id']}", font=("Arial", 8), bg=bg_color ).pack(pady=2) - # Get detail transaksi - transaksi_data = transaksi_get(int(transaksi_id)) - if transaksi_data: - tid, uid, meja_num, total, status_trx, promo, subtotal, item_disc, promo_disc, tanggal = transaksi_data - - tk.Label( - card, - text=f"Total: Rp {total:,.0f}", - font=("Arial", 8, "bold"), - bg=bg_color, - fg='#1976D2' - ).pack(pady=2) - - tk.Label( - card, - text=f"Status: {status_trx.upper()}", - font=("Arial", 7), - bg=bg_color - ).pack(pady=2) + tk.Label( + card, + text=f"Total: {format_currency(transaksi_info['total'])}", + font=("Arial", 8, "bold"), + bg=bg_color, + fg='#1976D2' + ).pack(pady=2) + + tk.Label( + card, + text=f"Status: {transaksi_info['status'].upper()}", + font=("Arial", 7), + bg=bg_color + ).pack(pady=2) - # Tombol aksi + # Tombol action btn_frame = tk.Frame(card, bg=bg_color) btn_frame.pack(pady=(10, 0)) - if status == 'terisi': - # Tombol Tutup Meja (hanya jika sudah dibayar) - if transaksi_id: - transaksi_data = transaksi_get(int(transaksi_id)) - if transaksi_data and transaksi_data[4] == 'dibayar': - tk.Button( - btn_frame, - text="āœ… Tutup Meja", - font=("Arial", 9, "bold"), - bg='#4CAF50', - fg='white', - width=12, - borderwidth=0, - cursor='hand2', - command=lambda n=nomor: self.tutup_meja(n) - ).pack() - else: - tk.Label( - btn_frame, - text="ā³ Menunggu Pembayaran", - font=("Arial", 8), - bg=bg_color, - fg='orange' - ).pack() + if status == 'terisi' and transaksi_info: + if transaksi_info['status'] == 'dibayar': + tk.Button( + btn_frame, + text="āœ… Tutup Meja", + font=("Arial", 9, "bold"), + bg='#4CAF50', + fg='white', + width=12, + borderwidth=0, + cursor='hand2', + command=lambda n=nomor: self.tutup_meja(n) + ).pack() + else: + tk.Label( + btn_frame, + text="ā³ Menunggu Pembayaran", + font=("Arial", 8), + bg=bg_color, + fg='orange' + ).pack() else: - # Meja kosong - tampilkan info saja tk.Label( btn_frame, text="Siap digunakan", @@ -3922,26 +4442,48 @@ class App: if col >= 5: # 5 meja per row col = 0 row += 1 - - # Configure grid weights - for i in range(5): - self.meja_cards_frame.columnconfigure(i, weight=1) - - + + def tutup_meja(self, nomor_meja): - """Tutup meja dan reset status""" - # Konfirmasi - if not messagebox.askyesno("Konfirmasi", f"Tutup meja {nomor_meja}?\n\nPastikan pelanggan sudah selesai dan transaksi sudah dibayar."): + """Tutup meja dengan validasi - VERSI BARU""" + # Validasi nomor meja + is_valid, msg = validate_meja_number(nomor_meja) + if not is_valid: + messagebox.showerror("Invalid Input", msg) return - # Tutup meja - success = meja_tutup(nomor_meja) + # Cek status transaksi + meja_data = meja_get(nomor_meja) + if meja_data and meja_data[2]: # ada transaksi_id + try: + transaksi_data = transaksi_get(int(meja_data[2])) + if transaksi_data and transaksi_data[4] != 'dibayar': + messagebox.showwarning( + "Belum Dibayar", + f"Transaksi belum dibayar!\n" + f"Status saat ini: {transaksi_data[4].upper()}\n\n" + f"Silakan proses pembayaran dulu." + ) + return + except: + pass - if success: - messagebox.showinfo("āœ… Berhasil", f"Meja {nomor_meja} berhasil ditutup dan siap digunakan lagi") - self.reload_meja_status() - else: - messagebox.showerror("āŒ Gagal", f"Gagal menutup meja {nomor_meja}") + # Konfirmasi + if messagebox.askyesno( + "Konfirmasi", + f"Tutup meja {nomor_meja}?\n\nMeja akan direset dan siap digunakan lagi." + ): + success = meja_tutup(nomor_meja) + if success: + messagebox.showinfo( + "āœ… Berhasil", + f"Meja {nomor_meja} berhasil direset dan siap digunakan" + ) + self.reload_meja_status() + else: + messagebox.showerror("āŒ Gagal", f"Gagal menutup meja {nomor_meja}") + + # ======================================== diff --git a/menu.csv b/menu.csv index 148d1ae..7f35564 100644 --- a/menu.csv +++ b/menu.csv @@ -9,5 +9,5 @@ id,nama,kategori,harga,stok,foto,tersedia,item_discount_pct 8,Beef Yakiniku,Makanan,25000.0,13,img/Beef-Yakiniku.jpg,1,4.0 9,Osaka Curry,Makanan,30000.0,15,img/osaka_curry.jpg,1,10.0 10,Choco Pudding,Dessert,20000.0,16,img/chocolate-pudding.jpg,1,8.0 -11,Totoro Dango,Dessert,18000.0,10,img/dango.jpg,1,4.0 +11,Totoro Dango,Dessert,18000.0,9,img/dango.jpg,1,4.0 12,Cheese Cake,Dessert,32000.0,9,img/cheese-cake.jpg,1,6.0 diff --git a/pembayaran.csv b/pembayaran.csv index 252c4fb..b76fd6b 100644 --- a/pembayaran.csv +++ b/pembayaran.csv @@ -1,2 +1,3 @@ id,transaksi_id,metode_pembayaran,jumlah_bayar,status_pembayaran,tanggal_bayar,struk 1,4,ewallet-gopay,20000.0,sukses,2025-12-13 17:42:14, +2,8,cash,50000.0,sukses,2025-12-14 01:46:20, diff --git a/transaksi.csv b/transaksi.csv index 6978a5a..5d101e6 100644 --- a/transaksi.csv +++ b/transaksi.csv @@ -6,3 +6,4 @@ id,user_id,nomor_meja,total,status,promo_code,subtotal,item_discount,promo_disco 5,1,2,59500.0,dibayar,CAFETOTORO,69000.0,2500.0,7000.0,2025-12-13 20:12:35 6,4,2,41965.0,diproses,MERDEKA,80000.0,3700.0,34335.0,2025-12-13 22:18:23 7,4,2,59180.0,dibayar,MERDEKA,110000.0,2400.0,48420.0,2025-12-13 22:22:02 +8,4,1,17280.0,dibayar,,18000.0,720.0,0.0,2025-12-14 01:44:46