9
Dasbor, Laporan, dan Ekspor Berkas
Modul laporan tidak menyimpan tabel ringkasan. Setiap kali endpoint dipanggil, angka dihitung ulang dari Transaksi, Retur, ItemTransaksi, ReturItem, dan BatchProduk. Perhitungan tinggal di data.py, endpoint baca dan ekspor tinggal di views.py, dan penulisan berkas tinggal di ekspor.py (backend/laporan/views.py:45-611).
Lima endpoint didaftarkan langsung di urlpatterns, bukan lewat router (backend/config/urls.py:42-462).
| Endpoint | Kelas view | Izin yang diminta | Parameter |
|---|---|---|---|
/api/dasbor/ | DasborView | LIHAT_PRODUK | – |
/api/laporan/penjualan/ | LaporanPenjualanView | LIHAT_LAPORAN | dari, sampai, per, ekspor |
/api/laporan/produk-terlaris/ | LaporanTerlarisView | LIHAT_LAPORAN | dari, sampai, ekspor |
/api/laporan/stok/ | LaporanStokView | LIHAT_LAPORAN | ekspor |
/api/laporan/piutang/ | LaporanPiutangView | LIHAT_LAPORAN | ekspor |
Kelas dasar LaporanView menetapkan izin = LIHAT_LAPORAN, lalu tiap subclass hanya mengisi judul, nama_file, kolom, dan ambil_baris (backend/laporan/views.py:68-943). Aturan izin itu sendiri dijelaskan di Autentikasi, 2FA, dan Izin Peran.
Jalur angka sampai unduhan
graph LR
TX["Transaksi sah"] --> PJ["data.penjualan"]
RT["Retur"] --> PJ
PJ --> V["LaporanView.get"]
V -->|"ekspor=xlsx"| X["ekspor.ke_xlsx"]
V -->|"ekspor=pdf"| P["ekspor.ke_pdf"]
V -->|"tanpa ekspor"| J["Response JSON"]
X --> XF["unduhan .xlsx"]
P --> PF["unduhan .pdf"]
style TX fill:#336791,color:#fff
style RT fill:#336791,color:#fff
style X fill:#2d7d46,color:#fff
style P fill:#2d7d46,color:#fff
style J fill:#8b5cf6,color:#fff
%% fig: Cabang keluaran satu endpoint laporan
%% desc: Transaksi dan retur masuk ke data.penjualan, lalu get memilih JSON, xlsx, atau PDF sesuai parameter ekspor.LaporanView.get memeriksa nilai ekspor secara berurutan: xlsx memanggil ekspor.ke_xlsx, pdf memanggil ekspor.ke_pdf, dan nilai lain apa pun jatuh ke Response(self.serializer_class(baris, many=True).data) (backend/laporan/views.py:45-611).
Sumber angka di data.py
_transaksi_sah adalah satu-satunya penyaring status yang dipakai bersama: Transaksi.objects.exclude(status=StatusTransaksi.DIBATALKAN). Semua laporan yang menyebut "sah" memakai fungsi itu (backend/laporan/data.py:19-204).
penjualan(dari, sampai, per) mengelompokkan dengan TruncDay atau TruncMonth sesuai per, menjumlahkan Count("id") dan Sum("total") pada transaksi sah, lalu mengurangkan Sum("total") retur yang bertanggal di dalam rentang. Docstring-nya menyatakan retur mengurangi penjualan pada tanggal retur, bukan tanggal nota, sehingga neto satu periode bisa berubah ketika retur dibuat belakangan (backend/laporan/data.py:23-605).
produk_terlaris(dari, sampai, batas) menjumlahkan Sum(F("jumlah") * F("isi_botol")) dan Sum("subtotal") per produk, mengurangkan botol dan nilai retur dari ReturItem, mengurutkan menurun berdasarkan botol_terjual, lalu memotong hasil bila batas diberikan (backend/laporan/data.py:63-906).
stok() memakai dengan_stok(...), jadi stok_botol hanya menghitung batch yang masih layak jual, ditambah stok_kedaluwarsa, kedaluwarsa_terdekat, stok_teks, dan status dari status_stok(p.stok_tersedia, p.stok_minimum) (backend/laporan/data.py:93-1087).
piutang() mengambil transaksi sah bersisa sisa_tagihan__gt=0, mengurutkan berdasarkan jatuh_tempo lalu tanggal, dan menghitung terlambat_hari sebagai max((hari_ini - t.jatuh_tempo).days, 0) dengan nilai 0 ketika jatuh_tempo kosong (backend/laporan/data.py:111-1338).
batch_hampir_kedaluwarsa(batas=10) memakai tenggat timezone.localdate() + timedelta(days=settings.HARI_HAMPIR_KEDALUWARSA) dan hanya mengambil batch dengan sisa__gt=0 (backend/laporan/data.py:136-1439).
tren_penjualan(hari=14) membangun rentang mundur dan mengisi hari tanpa transaksi dengan NOL, sehingga grafik selalu punya jumlah titik yang sama (backend/laporan/data.py:146-15310).
| Fungsi | Keluaran kunci | Dipakai view |
|---|---|---|
penjualan | periode, jumlah_transaksi, bruto, retur, neto | LaporanPenjualanView, DasborView |
produk_terlaris | sku, nama, botol_terjual, nilai | LaporanTerlarisView, DasborView |
stok | stok_botol, stok_teks, stok_kedaluwarsa, status | LaporanStokView |
piutang | nomor, sisa, terlambat_hari | LaporanPiutangView |
batch_hampir_kedaluwarsa | daftar BatchProduk | DasborView |
tren_penjualan | tanggal, neto | DasborView |
Parameter periode dan ekspor
PARAM_PERIODE mendeklarasikan dari dan sampai bertipe OpenApiTypes.DATE, sedangkan PARAM_EKSPOR mendeklarasikan ekspor dengan enum=["xlsx", "pdf"] untuk skema OpenAPI (backend/laporan/views.py:29-3311). Skema hasilnya didokumentasikan di Endpoint API dan Skema OpenAPI.
ambil_periode membaca kedua parameter lewat parse_date, memakai default hari_ini.replace(day=1) untuk dari dan hari_ini untuk sampai, lalu melempar ValidationError({"dari": ["Tanggal awal setelah tanggal akhir."]}) bila rentang terbalik (backend/laporan/views.py:36-4212). LaporanPenjualanView menambahkan parameter per dengan pilihan hari atau bulan, dan hanya nilai bulan yang mengubah pengelompokan (backend/laporan/views.py:80-9013).
LaporanStokView dan LaporanPiutangView mengabaikan periode dan mengembalikan string periode kosong, karena datanya adalah potret saat ini, bukan rentang tanggal (backend/laporan/views.py:119-13914, backend/laporan/views.py:142-16315).
teks_periode menyusun label rentang berformat dd/mm/YYYY yang dipakai sebagai kepala berkas PDF (backend/laporan/views.py:64-6516).
Urutan satu request ekspor
sequenceDiagram
participant K as Klien
participant V as LaporanView.get
participant D as data.penjualan
participant E as ekspor
K->>V: GET laporan penjualan dengan dari, sampai, per, ekspor
V->>V: ambil_periode
Note over V: dari > sampai dibalas 400 "Tanggal awal setelah tanggal akhir."
V->>D: penjualan(dari, sampai, per)
D-->>V: baris periode, bruto, retur, neto
alt ekspor=xlsx
V->>E: ke_xlsx
E-->>K: attachment laporan-penjualan.xlsx
else ekspor=pdf
V->>E: ke_pdf
E-->>K: attachment laporan-penjualan.pdf
else tanpa ekspor
V-->>K: Response JSON
end
%% fig: Siklus satu request laporan sampai unduhan
%% desc: Parameter divalidasi dulu, angka dihitung dari data.penjualan, lalu cabang ekspor memilih xlsx, PDF, atau JSON.Judul dan nama berkas tiap laporan ditetapkan sebagai atribut kelas: "Laporan penjualan" dengan "laporan-penjualan", "Produk terlaris" dengan "produk-terlaris", "Laporan stok" dengan "laporan-stok", dan "Laporan piutang" dengan "laporan-piutang" (backend/laporan/views.py:97-11617).
Ekspor xlsx dengan openpyxl
Kolom adalah alias tuple[str, str, str] berisi kunci, label, dan jenis dengan nilai yang dikenal teks, angka, uang, atau tanggal (backend/laporan/ekspor.py:1518).
ke_xlsx membuat Workbook, memotong judul lembar menjadi 31 karakter karena batas Excel, menulis header tebal di baris pertama, lalu mengisi sel mulai baris 2 (backend/laporan/ekspor.py:24-4919). Nilai Decimal diubah menjadi float, kolom uang diberi number_format "#,##0.00", dan kolom tanggal diberi "dd/mm/yyyy" (backend/laporan/ekspor.py:33-4220).
Setiap string dari pengguna ditulis dengan sel.data_type = "s" agar Excel tidak menafsirkannya sebagai rumus. _unduhan lalu mengirim byte dengan Content-Disposition: attachment; filename="<nama>.xlsx" (backend/laporan/ekspor.py:18-2121).
⚠️ Tanpa
data_type = "s", nama pelanggan seperti=HYPERLINK("http://x","y")akan dieksekusi Excel saat berkas dibuka.test_ekspor_excel_tidak_menjalankan_rumusmembuat pelanggan dengan nama itu dan memastikan sel tetap bertipes(backend/tests/test_laporan.py:81-10022).
Ekspor PDF dengan WeasyPrint
ke_pdf mengimpor weasyprint di dalam fungsi, sehingga kegagalan memuat pustaka sistem hanya terasa saat ekspor PDF diminta. HTML dirender dari laporan/tabel.html dengan konteks perusahaan dari settings.NAMA_PERUSAHAAN, judul, periode, dicetak, kepala, dan baris (backend/laporan/ekspor.py:62-8423).
Isi sel HTML dibentuk _tampil: None menjadi string kosong, kolom uang diformat lewat rupiah, dan objek date menjadi %d/%m/%Y (backend/laporan/ekspor.py:52-5924).
Template menetapkan @page { size: A4 landscape; ... } beserta penomoran "Halaman N dari M" di sudut kanan bawah, dan thead { display: table-header-group; } agar header tabel diulang pada setiap halaman (backend/laporan/templates/laporan/tabel.html:625). Ketika tidak ada baris, tabel menampilkan satu sel "Tidak ada data." (backend/laporan/templates/laporan/tabel.html:20-2826).
⚠️ Test PDF bergantung pada pustaka Pango. Bila Pango tidak terpasang, test berhenti lewat
pytest.skip("Pustaka Pango tidak tersedia di mesin ini")alih-alih gagal, jadi ekspor PDF bisa lolos pipeline tanpa pernah benar-benar dijalankan (backend/tests/test_laporan.py:125-13827). Rincian pipeline ada di Pengujian dan Pipeline CI.
Dasbor yang mengikuti izin
DasborView hanya mewajibkan LIHAT_PRODUK, tetapi isi response bergantung pada izin pengguna. Field yang tidak boleh dilihat tetap dikirim sebagai None, dan DasborSerializer mendeklarasikan semuanya dengan allow_null=True (backend/laporan/serializers.py:68-7928).
Izin di request.user.izin | Field yang terisi |
|---|---|
KELOLA_TRANSAKSI | jumlah_transaksi_hari_ini, tagihan_jatuh_tempo |
LIHAT_LAPORAN | penjualan_hari_ini, total_piutang, pembatalan_menunggu, tren, terlaris |
SETUJUI_OPNAME | opname_menunggu |
| selalu | stok_habis, stok_menipis, hampir_kedaluwarsa |
Bagian stok selalu dihitung: produk aktif dinilai dengan status_stok, setiap produk yang statusnya bukan "aman" dikumpulkan ke rendah, lalu diurutkan menaik berdasarkan stok_tersedia dan dipotong 10 baris. stok_habis menghitung produk dengan stok_tersedia <= 0 (backend/laporan/views.py:166-22729).
Cabang LIHAT_LAPORAN memakai data.tren_penjualan() tanpa argumen sehingga memakai 14 hari, dan data.produk_terlaris(bulan_lalu, hari_ini, batas=5) dengan bulan_lalu = hari_ini - timedelta(days=30) (backend/laporan/views.py:214-22330).
test_dasbor_mengikuti_izin mengunci perilaku ini: staf gudang mendapat penjualan_hari_ini is None dan tren is None tetapi stok_menipis[0]["stok_teks"] == "4 botol", admin penjualan mendapat jumlah_transaksi_hari_ini == 1 tanpa penjualan, dan pemilik mendapat tren sepanjang 14 (backend/tests/test_laporan.py:103-12231).
Impor produk sebagai pasangan ekspor
Berkas produk bergerak dua arah dengan pustaka yang sama. KOLOM menetapkan sembilan kolom templat dan MAKS_BARIS = 5000 menjadi batas jumlah baris yang diproses (backend/produk/impor.py:11-2232).
graph TD
T["impor.buat_template"] --> F["berkas xlsx"]
F --> B["impor.baca_baris"]
B -->|"galat kosong"| S["impor.simpan_baris"]
B -->|"galat terisi"| R["ValidationError 400"]
S --> DB["Produk dan Kategori"]
style T fill:#f59e0b,color:#000
style F fill:#f59e0b,color:#000
style DB fill:#336791,color:#fff
style R fill:#dc2626,color:#fff
style S fill:#2d7d46,color:#fff
%% fig: Perjalanan berkas impor produk
%% desc: buat_template menghasilkan kerangka xlsx, baca_baris memvalidasi isinya, dan simpan_baris menulis produk ke database.buat_template menulis header KOLOM plus satu baris contoh ["AMK-600", "Air Mineral 600 ml", "Botol", 600, "Botol", 240, 3500, 24, 72000] (backend/produk/impor.py:25-3333).
baca_baris membaca lembar pertama dengan read_only=True, membandingkan header terhadap KOLOM dan mengembalikan pesan "Kolom tidak sesuai template: ..." bila berbeda, lalu mengumpulkan galat per baris seperti "Baris {nomor}: angka tidak valid." dan "Baris {nomor}: SKU {sku} ganda di berkas." (backend/produk/impor.py:51-10334).
simpan_baris berjalan di dalam @transaction.atomic, melewati SKU yang sudah ada, dan mengembalikan {"dibuat": ..., "dilewati": ...} sehingga berkas yang sama aman diimpor ulang (backend/produk/impor.py:106-11935).
Endpoint POST /api/produk/impor/ menolak berkas lebih besar dari settings.IMPOR_MAKS_BYTE dengan pesan "Ukuran berkas maksimal 5 MB.", dan seluruh galat baris dikembalikan sekaligus lewat ValidationError({"berkas": galat}) tanpa menyimpan sebagian. GET /api/produk/template-impor/ mengirim template-produk.xlsx (backend/produk/views.py:129-15236).