Lompat ke konten
dokumentasi.id
Commit 32b2830
v1
Star0

Kamu membaca versi lama. Halaman ini hanya bisa dibaca.

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-61).

Lima endpoint didaftarkan langsung di urlpatterns, bukan lewat router (backend/config/urls.py:42-46).

EndpointKelas viewIzin yang dimintaParameter
/api/dasbor/DasborViewLIHAT_PRODUK–
/api/laporan/penjualan/LaporanPenjualanViewLIHAT_LAPORANdari, sampai, per, ekspor
/api/laporan/produk-terlaris/LaporanTerlarisViewLIHAT_LAPORANdari, sampai, ekspor
/api/laporan/stok/LaporanStokViewLIHAT_LAPORANekspor
/api/laporan/piutang/LaporanPiutangViewLIHAT_LAPORANekspor

Kelas dasar LaporanView menetapkan izin = LIHAT_LAPORAN, lalu tiap subclass hanya mengisi judul, nama_file, kolom, dan ambil_baris (backend/laporan/views.py:68-94). 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.
Fig. 1: Cabang keluaran satu endpoint laporanTransaksi 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-61).

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-20).

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-60).

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-90).

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-108).

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-133).

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-143).

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-153).

FungsiKeluaran kunciDipakai view
penjualanperiode, jumlah_transaksi, bruto, retur, netoLaporanPenjualanView, DasborView
produk_terlarissku, nama, botol_terjual, nilaiLaporanTerlarisView, DasborView
stokstok_botol, stok_teks, stok_kedaluwarsa, statusLaporanStokView
piutangnomor, sisa, terlambat_hariLaporanPiutangView
batch_hampir_kedaluwarsadaftar BatchProdukDasborView
tren_penjualantanggal, netoDasborView

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-33). 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-42). LaporanPenjualanView menambahkan parameter per dengan pilihan hari atau bulan, dan hanya nilai bulan yang mengubah pengelompokan (backend/laporan/views.py:80-90).

LaporanStokView dan LaporanPiutangView mengabaikan periode dan mengembalikan string periode kosong, karena datanya adalah potret saat ini, bukan rentang tanggal (backend/laporan/views.py:119-139, backend/laporan/views.py:142-163).

teks_periode menyusun label rentang berformat dd/mm/YYYY yang dipakai sebagai kepala berkas PDF (backend/laporan/views.py:64-65).

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.
Fig. 2: Siklus satu request laporan sampai unduhanParameter 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-116).

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:15).

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-49). Nilai Decimal diubah menjadi float, kolom uang diberi number_format "#,##0.00", dan kolom tanggal diberi "dd/mm/yyyy" (backend/laporan/ekspor.py:33-42).

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-21).

⚠️ Tanpa data_type = "s", nama pelanggan seperti =HYPERLINK("http://x","y") akan dieksekusi Excel saat berkas dibuka. test_ekspor_excel_tidak_menjalankan_rumus membuat pelanggan dengan nama itu dan memastikan sel tetap bertipe s (backend/tests/test_laporan.py:81-100).

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-84).

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-59).

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:6). Ketika tidak ada baris, tabel menampilkan satu sel "Tidak ada data." (backend/laporan/templates/laporan/tabel.html:20-28).

⚠️ 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-138). 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-79).

Izin di request.user.izinField yang terisi
KELOLA_TRANSAKSIjumlah_transaksi_hari_ini, tagihan_jatuh_tempo
LIHAT_LAPORANpenjualan_hari_ini, total_piutang, pembatalan_menunggu, tren, terlaris
SETUJUI_OPNAMEopname_menunggu
selalustok_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-227).

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-223).

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-122).

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-22).

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.
Fig. 3: Perjalanan berkas impor produkbuat_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-33).

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-103).

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-119).

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-152).