6
Laporan dan Ekspor Berkas
Modul backend/laporan menyediakan empat laporan sekaligus: penjualan, produk terlaris, stok, dan piutang. Keempatnya tidak menulis ulang logika response, karena semuanya diturunkan dari satu kelas dasar LaporanView. Kelas dasar itu hanya menyimpan tiga hal: izin LIHAT_LAPORAN, atribut tampilan (judul, nama_file, kolom), dan satu method ambil_baris yang wajib diisi subkelas. Method get yang menentukan bentuk response ada di kelas dasar, sedangkan serializer_class baru dideklarasikan di masing-masing subkelas. backend/laporan/views.py:45-611
Satu get untuk tiga bentuk response
Method get memanggil ambil_baris lebih dulu, lalu membaca query param ekspor. Nilai xlsx diarahkan ke ekspor.ke_xlsx, nilai pdf ke ekspor.ke_pdf dengan tambahan parameter periode, dan nilai lain apa pun jatuh ke Response(self.serializer_class(baris, many=True).data). Artinya ekspor tidak divalidasi oleh view: nilai asing seperti ekspor=csv tidak menghasilkan error, melainkan response JSON biasa. backend/laporan/views.py:54-612
graph TD
req["GET /api/laporan/..."] --> guard{"PunyaIzin: LIHAT_LAPORAN"}
guard -->|"izin cocok"| get["LaporanView.get"]
guard -->|"izin tidak ada"| tolak["permintaan ditolak"]
get --> ambil["ambil_baris() subkelas"]
ambil --> periode{"butuh periode?"}
periode -->|"penjualan, terlaris"| pr["ambil_periode: dari, sampai"]
periode -->|"stok, piutang"| kosong["periode = string kosong"]
pr --> baris["list of dict dari data.py"]
kosong --> baris
baris --> pilih{"query param ekspor"}
pilih -->|"kosong atau nilai lain"| json["Response serializer_class many"]
pilih -->|"xlsx"| xlsx["ekspor.ke_xlsx"]
pilih -->|"pdf"| pdf["ekspor.ke_pdf"]
pr -.->|"dari lewat sampai"| err["ValidationError 400"]
style req fill:#f59e0b,color:#000
style guard fill:#f59e0b,color:#000
style get fill:#2d7d46,color:#fff
style tolak fill:#dc2626,color:#fff
style ambil fill:#336791,color:#fff
style periode fill:#f59e0b,color:#000
style pr fill:#336791,color:#fff
style kosong fill:#336791,color:#fff
style baris fill:#336791,color:#fff
style pilih fill:#f59e0b,color:#000
style json fill:#2d7d46,color:#fff
style xlsx fill:#8b5cf6,color:#fff
style pdf fill:#8b5cf6,color:#fff
style err fill:#dc2626,color:#fff
%% fig: Cabang response laporan
%% desc: Satu method get memilih antara response JSON, berkas xlsx, dan berkas PDF berdasarkan query param ekspor.Keempat endpoint didaftarkan langsung di config/urls.py, bukan lewat router. backend/config/urls.py:43-463
| Path | View | judul | nama_file | Parameter yang didokumentasikan |
|---|---|---|---|---|
api/laporan/penjualan/ | LaporanPenjualanView | Laporan penjualan | laporan-penjualan | dari, sampai, per, ekspor |
api/laporan/produk-terlaris/ | LaporanTerlarisView | Produk terlaris | produk-terlaris | dari, sampai, ekspor |
api/laporan/stok/ | LaporanStokView | Laporan stok | laporan-stok | ekspor |
api/laporan/piutang/ | LaporanPiutangView | Laporan piutang | laporan-piutang | ekspor |
Dekorator extend_schema di tiap subkelas menyusun daftar parameter dari dua konstanta bersama, PARAM_PERIODE dan PARAM_EKSPOR, lalu menambahkan per khusus laporan penjualan. PARAM_EKSPOR mendeklarasikan enum xlsx dan pdf. backend/laporan/views.py:29-334 backend/laporan/views.py:85-925
Parameter periode dan pengelompokan
ambil_periode membaca dari dan sampai lewat parse_date. Bila tidak diisi atau tidak terparse, dari menjadi hari pertama bulan berjalan (hari_ini.replace(day=1)) dan sampai menjadi hari ini. Urutan terbalik ditolak dengan ValidationError yang memakai kunci dari. backend/laporan/views.py:36-426
⚠️ Pesan error periode muncul sebagai
{"dari": ["Tanggal awal setelah tanggal akhir."]}dengan status 400, dan test-nya menegaskan status itu untukdari=2026-02-01sertasampai=2026-01-01. Klien yang hanya mengirimsampaitanpadarijustru mendapat rentang dari awal bulan, bukan rentang dari awal data. backend/tests/test_laporan.py:53-557
| Parameter | Dipakai di | Nilai yang diterima | Perilaku bawaan |
|---|---|---|---|
dari | penjualan, produk terlaris | tanggal hasil parse_date | hari pertama bulan berjalan |
sampai | penjualan, produk terlaris | tanggal hasil parse_date | hari ini menurut timezone.localdate() |
per | penjualan | bulan mengaktifkan TruncMonth; nilai lain menjadi hari | hari |
ekspor | keempat laporan | xlsx, pdf | response JSON |
per dibaca dengan perbandingan ketat: hanya string bulan yang mengubah pengelompokan, selebihnya hari. backend/laporan/views.py:80-838 Test test_laporan_per_bulan memastikan periode pada baris pertama sama dengan tanggal 1 bulan berjalan. backend/tests/test_laporan.py:46-509
Label periode pada PDF dibuat oleh teks_periode dengan bentuk dd/mm/yyyy s.d. dd/mm/yyyy. Laporan stok dan piutang mengembalikan string kosong sebagai periode, sehingga kepala PDF-nya hanya memuat nama perusahaan, garis pemisah, dan waktu cetak. backend/laporan/views.py:64-6510 backend/laporan/views.py:134-13511 backend/laporan/views.py:158-15912
Sumber angka tiap laporan
Empat fungsi di data.py memikul seluruh query. Tidak ada view yang menulis ORM sendiri; laporan hanya merapikan hasil fungsi-fungsi ini. backend/laporan/views.py:45-611
graph TD
transaksi["Transaksi tanpa DIBATALKAN"] --> penjualan["data.penjualan"]
retur["Retur"] --> penjualan
item["ItemTransaksi"] --> terlaris["data.produk_terlaris"]
returitem["ReturItem"] --> terlaris
batch["BatchProduk sisa lebih dari nol"] --> stok["data.stok"]
produk["Produk aktif"] --> stok
transaksi --> piutang["data.piutang"]
penjualan --> vjual["LaporanPenjualanView"]
terlaris --> vterlaris["LaporanTerlarisView"]
stok --> vstok["LaporanStokView"]
piutang --> vpiutang["LaporanPiutangView"]
penjualan --> dasbor["DasborView"]
terlaris --> dasbor
style transaksi fill:#336791,color:#fff
style retur fill:#336791,color:#fff
style item fill:#336791,color:#fff
style returitem fill:#336791,color:#fff
style batch fill:#336791,color:#fff
style produk fill:#336791,color:#fff
style penjualan fill:#8b5cf6,color:#fff
style terlaris fill:#8b5cf6,color:#fff
style stok fill:#8b5cf6,color:#fff
style piutang fill:#8b5cf6,color:#fff
style vjual fill:#2d7d46,color:#fff
style vterlaris fill:#2d7d46,color:#fff
style vstok fill:#2d7d46,color:#fff
style vpiutang fill:#2d7d46,color:#fff
style dasbor fill:#2d7d46,color:#fff
%% fig: Model dan fungsi data yang memasok tiap laporan
%% desc: Setiap laporan membaca satu fungsi di data.py, dan fungsi itu hanya menyentuh model transaksi, retur, produk, serta batch.data.penjualanmemotong periode denganTruncMonthatauTruncDay, menghitungjumlahdanbrutodari transaksi sah, lalu mengurangireturpada tanggal returnya, bukan tanggal nota. Dokstring fungsi ini menyatakan aturan tersebut secara eksplisit. Transaksi berstatusDIBATALKANdisaring lewat helper_transaksi_sah. backend/laporan/data.py:19-2013 backend/laporan/data.py:23-6014data.produk_terlarismenjumlahkanSum(F("jumlah") * F("isi_botol"))sebagai botol danSum("subtotal")sebagai nilai, lalu menguranginya denganReturItemyang sepadan. Hasil diurutkan menurun berdasarkanbotol_terjual; argumenbatasmemotong daftar, danDasborViewmemakainya denganbatas=5. backend/laporan/data.py:63-9015 backend/laporan/views.py:22316data.stokmembangun baris daridengan_stok, sehinggastok_botolhanya menghitung batch yang masih layak jual, sementarastok_kedaluwarsamenghitung sisa batch yang sudah lewat tanggal. Kolomstatusberasal daristatus_stokyang mengembalikanhabis,menipis, atauaman. backend/laporan/data.py:93-10817 backend/produk/layanan.py:15-3418 backend/produk/layanan.py:37-4019data.piutangmengambil transaksi sah dengansisa_tagihan__gt=0, mengurutkan menurutjatuh_tempolalutanggal, dan menghitungterlambat_harisebagaimax((hari_ini - t.jatuh_tempo).days, 0)dengan nilai 0 bilajatuh_tempokosong. backend/laporan/data.py:111-13320
data.py juga menyediakan batch_hampir_kedaluwarsa dan tren_penjualan yang tidak dipakai endpoint laporan mana pun, melainkan oleh DasborView. tren_penjualan mengisi hari tanpa penjualan dengan NOL agar panjang deretnya selalu 14 hari. backend/laporan/data.py:136-15321
Bentuk baris dan definisi kolom
Tipe Kolom adalah alias tuple[str, str, str] untuk tiga posisi tetap: kunci data, label tampilan, dan jenis (teks, angka, uang, tanggal). Jenis inilah satu-satunya penentu format sel di xlsx dan perataan angka di PDF. backend/laporan/ekspor.py:1522
| Laporan | kolom (kunci, label, jenis) |
|---|---|
| penjualan | periode/tanggal, jumlah_transaksi/angka, bruto/uang, retur/uang, neto/uang |
| produk terlaris | sku/teks, nama/teks, botol_terjual/angka, nilai/uang |
| stok | sku/teks, nama/teks, stok_teks/teks, stok_botol/angka, stok_minimum/angka, stok_kedaluwarsa/angka, kedaluwarsa_terdekat/tanggal, status/teks |
| piutang | nomor/teks, pelanggan/teks, tanggal/tanggal, jatuh_tempo/tanggal, total/uang, retur/uang, terbayar/uang, sisa/uang, terlambat_hari/angka |
backend/laporan/views.py:72-7823 backend/laporan/views.py:101-10624 backend/laporan/views.py:123-13225 backend/laporan/views.py:146-15626
Untuk response JSON, field uang memakai helper uang() yang menghasilkan DecimalField(max_digits=18, decimal_places=2), sehingga angka uang terkirim sebagai string desimal seperti "14000.00". Field tanggal pada baris stok dan piutang dibuka dengan allow_null=True karena kedaluwarsa_terdekat dan jatuh_tempo boleh kosong. backend/laporan/serializers.py:4-1327 backend/laporan/serializers.py:24-3328 backend/laporan/serializers.py:36-4629
Dua jalur berkas
Kedua jalur berkas memakai satu helper unduhan bernama _unduhan, yang memasang Content-Disposition: attachment pada HttpResponse. backend/laporan/ekspor.py:18-2130
ke_xlsx menamai lembar kerja dengan judul[:31] karena batas panjang nama sheet, mencetak header tebal, dan mengubah Decimal menjadi float sebelum menulis sel. Nilai bertipe str selalu diberi data_type = "s" supaya teks kiriman pengguna tidak dieksekusi sebagai rumus Excel; komentar di kode menyebut alasan ini secara langsung. Format angka diatur lewat number_format #,##0.00 untuk uang dan dd/mm/yyyy untuk tanggal. Nama berkasnya {nama}.xlsx. backend/laporan/ekspor.py:24-4931
jenis | Sel xlsx | Teks PDF lewat _tampil |
|---|---|---|
teks | data_type = "s" | str(nilai) |
angka | nilai apa adanya | str(nilai) |
uang | number_format #,##0.00 | rupiah(nilai) |
tanggal | number_format dd/mm/yyyy | nilai.strftime("%d/%m/%Y") |
_tampil mengembalikan string kosong untuk None, dan hanya kolom berjenis uang serta angka yang diberi penanda angka untuk perataan kanan. backend/laporan/ekspor.py:52-5932
ke_pdf mengimpor weasyprint di dalam fungsi, merender laporan/tabel.html dengan konteks perusahaan dari settings.NAMA_PERUSAHAAN, judul, periode, dicetak dari timezone.localtime(), serta kepala dan baris yang sudah dipetakan ke bentuk siap tampil. Hasil HTML(string=html).write_pdf() dikirim sebagai application/pdf dengan nama {nama}.pdf. backend/laporan/ekspor.py:62-8333
Template tabel.html mengatur halaman A4 landscape dan mencetak "Halaman X dari Y" di sudut kanan bawah. Header tabel diulang di setiap halaman lewat thead { display: table-header-group; }, dan tabel tanpa baris menampilkan teks Tidak ada data.. backend/laporan/templates/laporan/tabel.html:634 backend/laporan/templates/laporan/tabel.html:20-2835
⚠️ PDF bergantung pada pustaka sistem GTK/Pango. Di Windows, PDF nota dan laporan tidak jalan tanpa pustaka itu, sementara fitur lain tetap normal dan image Docker sudah memuatnya. Test PDF melewati dirinya sendiri bila
import weasyprintgagal denganOSError, jadi kegagalan dependensi ini tidak terlihat sebagai test gagal. Versi pustaka yang dipatok adalahopenpyxl==3.1.5danweasyprint==70.0. README.md:4736 backend/requirements.txt:7-837 backend/tests/test_laporan.py:125-12938
Izin dan batas modul
Semua laporan memakai izin LIHAT_LAPORAN yang nilainya string lihat_laporan. Di PunyaIzin, view tanpa deklarasi izin selalu ditolak, dan view dengan deklarasi hanya lolos bila string itu ada di pengguna.izin. Pada IZIN_PERAN, hanya peran pemilik yang memegang LIHAT_LAPORAN, karena admin_penjualan hanya mendapat LIHAT_PRODUK dan KELOLA_TRANSAKSI, sedangkan staf_gudang mendapat LIHAT_PRODUK dan KELOLA_STOK. backend/inti/izin.py:1039 backend/inti/izin.py:31-3540 backend/inti/izin.py:52-6441
DasborView tinggal di modul yang sama tetapi memakai izin LIHAT_PRODUK dan mengisi field keuangan secara bertahap: blok KELOLA_TRANSAKSI mengisi hitungan transaksi hari ini dan tagihan jatuh tempo, blok LIHAT_LAPORAN mengisi penjualan_hari_ini, total_piutang, pembatalan_menunggu, tren, dan terlaris, sedangkan opname_menunggu menunggu SETUJUI_OPNAME. Field yang tidak terisi tetap bernilai None dan serializer membiarkannya null. Cara frontend memakai response ini dibahas di Frontend: Halaman dan Pengambilan Data. backend/laporan/views.py:166-17242 backend/laporan/views.py:208-22743
Yang dikunci test
| Test | Yang dijaga |
|---|---|
test_laporan_penjualan_neto_retur_dan_batal | transaksi batal tidak dihitung; bruto 14000.00, retur 3500.00, neto 10500.00; botol_terjual 3 |
test_laporan_per_bulan | periode sama dengan tanggal 1 bulan berjalan untuk per=bulan |
test_periode_terbalik_ditolak | status 400 saat dari melewati sampai |
test_laporan_piutang_dan_terlambat | sisa 7000.00 dan terlambat_hari 3 |
test_ekspor_excel_tidak_menjalankan_rumus | cell(row=2, column=2).data_type == "s" untuk pelanggan bernama =HYPERLINK("http://x","y") |
test_nota_dan_laporan_pdf | response ekspor=pdf diawali %PDF, atau test di-skip bila Pango tidak ada |
backend/tests/test_laporan.py:13-4344 backend/tests/test_laporan.py:81-10045 backend/tests/test_laporan.py:125-13846