4
Transaksi Penjualan, Retur, dan Piutang
Satu nota penjualan menyentuh empat tabel sekaligus: Transaksi beserta ItemTransaksi, alokasi batch di ItemTransaksiBatch, Pembayaran, dan Retur. Semua penulisan terjadi di dalam satu transaction.atomic() sehingga kegagalan di tengah tidak menyisakan nota setengah jadi. Halaman ini menelusuri satu request penjualan dari validasi satuan sampai berkas PDF nota, lalu pelunasan bertahap, retur, dan sisa tagihan yang dihitung database. Alokasi FEFO dan mutasi stok dibahas di Stok per Batch dan Alokasi FEFO, sedangkan rekap piutang per periode ada di Laporan dan Ekspor Berkas.
Nota, kunci idempoten, dan satu unit atomic
Transaksi menyimpan nomor unik dan kunci_idempoten berupa UUIDField dengan unique=True dan nilai awal uuid.uuid4 backend/pembelian/models.py:40-571. Kolom itulah yang mencegah klik ganda melahirkan dua nota.
buat_transaksi memeriksa kunci_idempoten lebih dulu dan langsung mengembalikan nota yang sudah ada. Bila belum ada, ia menjalankan _buat_transaksi di dalam transaction.atomic(); jika IntegrityError muncul karena dua request menyerbu bersamaan, ia membaca ulang nota dengan kunci yang sama sebelum meneruskan galat backend/pembelian/layanan.py:31-462. Test test_klik_ganda_tidak_mengurangi_stok_dua_kali mengirim payload identik dua kali dan memastikan nomor kedua respons sama, hanya ada satu baris Transaksi, dan sisa batch menjadi 8 backend/tests/test_pembelian.py:109-1193.
Nomor dokumen keluar dari nomor_berikutnya(f"TRX-{tanggal:%Y%m%d}"), yang menaikkan satu baris NomorUrut di bawah select_for_update sehingga dua nota tidak pernah berbagi nomor backend/inti/models.py:12-184.
sequenceDiagram
autonumber
participant FE as Frontend kasir
participant VW as TransaksiViewSet.create
participant SR as TransaksiBuatSerializer
participant LY as layanan.buat_transaksi
participant ST as _ambil_satuan dan _kunci_batch_fefo
participant DB as tabel transaksi
participant NP as nota_pdf
FE->>VW: POST /api/transaksi/ dengan kunci_idempoten
VW->>SR: is_valid raise_exception
SR-->>VW: validated_data
VW->>LY: buat_transaksi pengguna dan validated_data
LY->>DB: filter kunci_idempoten
alt nota sudah ada
DB-->>LY: nota lama
LY-->>VW: nota lama tanpa menulis apa pun
else nota baru
LY->>ST: _ambil_satuan lalu _kunci_batch_fefo
ST-->>LY: harga satuan dan batch terkunci
LY->>DB: simpan Transaksi dan ItemTransaksi
LY->>DB: ubah_stok negatif jenis penjualan per alokasi
LY->>DB: simpan Pembayaran bila terbayar lebih dari 0
LY-->>VW: transaksi_baru
end
VW->>SR: TransaksiDetailSerializer lewat _balas
SR-->>FE: 201 dengan payload detail
FE->>VW: GET /api/transaksi/id/nota/
VW->>NP: nota_pdf transaksi
NP-->>FE: application/pdf
%% fig: Siklus satu request penjualan sampai nota PDF
%% desc: Request POST diperiksa kunci idempotennya, dinilai harganya di server, lalu nota dan stok keluar ditulis dalam satu blok; GET nota mengubah nota yang sama menjadi PDF.Endpoint yang membuka jalur ini didaftarkan lewat router transaksi dan retur di config/urls.py, dengan izin per action sebagai berikut:
| Endpoint | Action | Service yang dipanggil | Izin |
|---|---|---|---|
POST /api/transaksi/ | create | layanan.buat_transaksi | KELOLA_TRANSAKSI |
POST /api/transaksi/{id}/bayar/ | bayar | layanan.catat_pelunasan | KELOLA_TRANSAKSI |
GET /api/transaksi/{id}/nota/ | nota | nota_pdf | KELOLA_TRANSAKSI |
POST /api/transaksi/{id}/ajukan-batal/ | ajukan_batal | layanan.ajukan_pembatalan | KELOLA_TRANSAKSI |
POST /api/transaksi/{id}/setujui-batal/ | setujui_batal | layanan.setujui_pembatalan | SETUJUI_PEMBATALAN |
POST /api/transaksi/{id}/tolak-batal/ | tolak_batal | layanan.tolak_pembatalan | SETUJUI_PEMBATALAN |
POST /api/retur/ | create | layanan.buat_retur | KELOLA_TRANSAKSI |
Pemetaan izin per action dan penerusan validated_data ke service ada di TransaksiViewSet backend/pembelian/views.py:67-1355, sedangkan ReturViewSet.create mengumpulkan transaksi, items, alasan, dan metode_pengembalian lalu memanggil layanan.buat_retur backend/pembelian/views.py:182-2136.
Harga dan total dihitung di server
_ambil_satuan memuat satuan lewat SatuanProduk.objects.dengan_harga(hari_ini), lalu menolak satuan atau produk yang tidak aktif dengan produk_nonaktif dan menolak satuan tanpa harga berlaku dengan harga_belum_ada backend/pembelian/layanan.py:49-657. Jadi payload klien tidak pernah menentukan harga.
Kebutuhan dihitung dalam botol lebih dulu: kebutuhan[s.produk_id] += data["jumlah"] * s.isi_botol, lalu dibandingkan dengan sum(b.sisa ...) pada batch terkunci. Kekurangan dilempar sebagai stok_tidak_cukup beserta rincian per produk backend/pembelian/layanan.py:96-1118. Total nota baru dijumlahkan dari harga server, sum(satuan[i["satuan"]].harga * i["jumlah"]), dan setiap ItemTransaksi menyimpan salinan nama_produk, nama_satuan, isi_botol, harga_satuan, serta subtotal backend/pembelian/layanan.py:113-1469 backend/pembelian/models.py:100-13510.
Snapshot itu yang menjaga nota lama tidak berubah saat harga naik. Test test_harga_dari_server_dan_nota_lama_tidak_berubah mengirim harga_satuan: "1" dan total: "1" di payload, tetap menerima 7000.00, lalu setelah harga satuan naik ke 4000 nota lama tetap terbaca 3500.00 backend/tests/test_pembelian.py:122-14111. Sisa alokasi batch ditulis per baris lewat ItemTransaksiBatch dan ubah_stok(batch, -ambil, JenisMutasi.PENJUALAN, ...), dengan batch dikunci urut id supaya dua transaksi tidak saling menunggu backend/pembelian/layanan.py:147-16612 backend/stok/layanan.py:47-5013.
Pembayaran bertahap dan sisa tagihan
Untuk nota tempo, terbayar diisi uang_muka; untuk nota tunai, terbayar diisi total penuh. Baris Pembayaran hanya dibuat bila terbayar > 0 backend/pembelian/layanan.py:147-16612. Aturan bentuk divalidasi lebih awal: jatuh_tempo wajib dan tidak boleh sebelum hari ini saat tempo aktif, dan uang_muka hanya boleh dikirim bersama tempo backend/pembelian/serializers.py:172-20114. Nilai uang muka yang menyamai total juga ditolak dengan uang_muka_lebih backend/pembelian/layanan.py:113-1469.
Pelunasan berikutnya masuk lewat catat_pelunasan: baris transaksi dikunci, status wajib selesai, jumlah yang melebihi t.sisa ditolak dengan bayar_lebih, lalu t.terbayar += jumlah disimpan dengan update_fields=["terbayar"] backend/pembelian/layanan.py:181-19415. PelunasanSerializer membatasi jumlah minimal 0.01 backend/pembelian/serializers.py:204-20716, dan tabel Pembayaran menegakkan pembayaran_positif agar tidak ada baris nol atau negatif backend/pembelian/models.py:138-15817.
Sisa tagihan bukan kolom biasa. sisa_tagihan adalah GeneratedField dengan ekspresi F("total") - F("total_retur") - F("terbayar") dan db_persist=True, sehingga nilainya dihitung database backend/pembelian/models.py:40-571. Constraint tagihan_tidak_minus menolak baris yang totalnya lebih kecil dari total_retur + terbayar, dan terbayar_tidak_minus menolak terbayar negatif backend/pembelian/models.py:80-8918. Di memori, properti sisa menghitung ulang nilai yang sama agar aman dipakai setelah objek diubah backend/pembelian/models.py:94-97. Label status_bayar — dibatalkan, lunas, jatuh_tempo, atau tempo — diturunkan dari sisa_tagihan dan jatuh_tempo backend/pembelian/serializers.py:20-27, dan filter daftar lunas, belum_lunas, serta jatuh_tempo memakai kolom turunan yang sama setelah transaksi dibatalkan dikeluarkan backend/pembelian/views.py:67-135.
Piutang per pelanggan dirangkum dengan Subquery atas Sum("sisa_tagihan") untuk transaksi bersisa lebih dari 0 dan tidak dibatalkan, lalu di-Coalesce ke Decimal("0") sebagai total_piutang backend/pembelian/views.py:43-6421. sisa_tagihan dan kunci_idempoten sengaja dikeluarkan dari auditlog karena keduanya turunan atau idempotensi murni backend/pembelian/apps.py:1322.
Retur: alokasi per batch dan pembagian uang
Retur tidak menunjuk baris nota, melainkan satu alokasi ItemTransaksiBatch yang punya jumlah_botol dan jumlah_diretur backend/pembelian/models.py:100-13510. buat_retur mengunci seluruh alokasi nota, menolak alokasi milik nota lain dengan alokasi_salah, menolak jumlah yang melampaui pembelian dengan retur_lebih, dan menolak retur berkondisi layak untuk batch yang sudah kedaluwarsa dengan retur_kedaluwarsa backend/pembelian/layanan.py:207-26323. Nomor retur memakai awalan RTR- dengan lebar tiga digit.
Nilai retur tidak dihitung per baris, melainkan dari selisih kumulatif bulatkan(item.subtotal * botol / total, ...), sehingga retur penuh selalu sama persis dengan subtotal item backend/pembelian/layanan.py:197-20424. Test test_retur_per_botol_dari_dus_tidak_selisih_pembulatan meretur satu dus isi 24 botol sebanyak 24 kali dan menjumlahkan totalnya tepat 78001 backend/tests/test_pembelian.py:278-30225.
Barang layak dikembalikan ke batch asal dengan JenisMutasi.RETUR; barang rusak dicatat masuk lalu langsung dikeluarkan lagi dengan JenisMutasi.RUSAK sehingga sisanya tidak bertambah backend/pembelian/layanan.py:207-26323. Model Retur menyimpan total, potong_piutang, dan pengembalian_uang sebagai tiga angka terpisah, dan ReturItem menyimpan kondisi per alokasi backend/pembelian/models.py:164-19226.
graph LR
RT["Retur.total dari _nilai_retur"] --> POTONG{"potong = min total, t.sisa"}
POTONG --> PP["Retur.potong_piutang"]
POTONG --> PU["Retur.pengembalian_uang"]
PP --> TR["Transaksi.total_retur"]
PU --> PB["Pembayaran jenis kembali"]
PU --> TB["Transaksi.terbayar dikurangi"]
TR --> STG["GeneratedField sisa_tagihan"]
TB --> STG
style RT fill:#8b5cf6,color:#fff
style POTONG fill:#f59e0b,color:#000
style PP fill:#2d7d46,color:#fff
style PU fill:#f59e0b,color:#000
style PB fill:#336791,color:#fff
style TR fill:#336791,color:#fff
style TB fill:#336791,color:#fff
style STG fill:#8b5cf6,color:#fff
%% fig: Pembagian nilai retur menjadi potong piutang dan uang kembali
%% desc: Nilai retur dipotong terhadap sisa tagihan lebih dulu; sisanya dibayarkan sebagai Pembayaran jenis kembali dan mengurangi terbayar, lalu sisa_tagihan dihitung ulang database.Pembagiannya satu baris: potong = min(total, t.sisa) dan kembali = total - potong. kembali yang lebih dari 0 dicatat sebagai Pembayaran berjenis JenisPembayaran.KEMBALI dengan referensi nomor retur, lalu t.total_retur += total dan t.terbayar -= kembali disimpan bersama backend/pembelian/layanan.py:265-29227. Test test_retur_tempo_memotong_piutang_dulu menunjukkan akibatnya: nota tempo dengan uang muka 10000 yang diretur 10500 menghasilkan potong_piutang 4000, pengembalian_uang 6500, sisa_tagihan 0, dan terbayar 3500 backend/tests/test_pembelian.py:248-27528.
ReturBuatSerializer menerima transaksi, daftar items berisi alokasi, jumlah_botol, dan kondisi, serta alasan dan metode_pengembalian backend/pembelian/serializers.py:220-23229.
Pembatalan dan siklus status
Status transaksi hanya punya tiga nilai: selesai, menunggu_batal, dan dibatalkan backend/pembelian/models.py:40-571. Semua service yang menyentuh uang atau stok memanggil _wajib_selesai, sehingga nota yang menunggu pembatalan tidak bisa dilunasi atau diretur backend/pembelian/layanan.py:169-17830.
stateDiagram-v2
[*] --> selesai: buat_transaksi
selesai --> selesai: catat_pelunasan atau buat_retur
selesai --> menunggu_batal: ajukan_pembatalan
menunggu_batal --> selesai: tolak_pembatalan
menunggu_batal --> dibatalkan: setujui_pembatalan
dibatalkan --> [*]
%% fig: Siklus status Transaksi
%% desc: Nota lahir sebagai selesai, hanya bisa diubah lewat pengajuan pembatalan, dan kembali ke selesai bila ditolak atau berakhir dibatalkan bila disetujui.ajukan_pembatalan menyimpan alasan_batal dan batal_diajukan_oleh lalu memindahkan status ke menunggu_batal; setujui_pembatalan mengembalikan sisa barang yang belum diretur ke batch asal dengan JenisMutasi.BATAL, mengembalikan seluruh terbayar sebagai Pembayaran jenis KEMBALI, dan menutup nota sebagai dibatalkan; tolak_pembatalan mengembalikan status ke selesai beserta alasan_tolak_batal backend/pembelian/layanan.py:295-35731. Karena persetujuan memakai izin SETUJUI_PEMBATALAN, pengaju dan penyetuju bisa berasal dari peran berbeda backend/pembelian/views.py:67-1355.
⚠️ Persetujuan pembatalan hanya mengembalikan barang yang
belum_diretur. Barang yang sudah diretur sebagairusaktetap tidak masuk kembali ke stok, jadi pembatalan tidak menggandakan barang backend/pembelian/layanan.py:295-35731.
Nota PDF
GET /api/transaksi/{id}/nota/ membalas HttpResponse berisi nota_pdf(transaksi) dengan content_type="application/pdf" dan Content-Disposition: inline bernama {nomor}.pdf backend/pembelian/views.py:173-17932. nota_pdf merender pembelian/nota.html lewat render_to_string dengan nama/jumlah/harga/subtotal per item, lalu total, total_retur, terbayar, dan sisa_tagihan, semuanya sudah diformat rupiah backend/pembelian/nota.py:10-3633 backend/inti/format.py:10-1434.
Template A5 itu mencetak baris Retur hanya bila total_retur bukan nol, baris Sisa tagihan hanya bila sisanya lebih dari nol, baris Jatuh tempo hanya bila tanggalnya ada, dan menyisipkan DIBATALKAN: {{ t.alasan_batal }} untuk nota berstatus dibatalkan backend/pembelian/templates/pembelian/nota.html:32-4735. Konversi PDF baru diimpor di dalam fungsi, sehingga weasyprint hanya dibutuhkan saat endpoint nota benar-benar dipanggil backend/pembelian/nota.py:10-3633.
Kode galat yang perlu dikenali
Semua penolakan aturan pencatatan dilempar sebagai AturanBisnisError dengan status_code 409, dan penangan_galat membentuk badan {kode, pesan, rincian} backend/inti/galat.py:8-1836. Galat validasi serializer memakai bentuk yang sama dengan kode: "validasi".
kode | Pemicu | Sumber |
|---|---|---|
produk_nonaktif | satuan atau produk sudah tidak aktif | backend/pembelian/layanan.py:49-657 |
harga_belum_ada | satuan tidak punya harga berlaku hari ini | backend/pembelian/layanan.py:49-657 |
stok_tidak_cukup | total botol terkunci kurang dari kebutuhan | backend/pembelian/layanan.py:96-1118 |
uang_muka_lebih | uang muka pada nota tempo menyamai total | backend/pembelian/layanan.py:113-1469 |
status_transaksi | nota tidak berstatus selesai atau tidak sedang menunggu pembatalan | backend/pembelian/layanan.py:169-17830 |
bayar_lebih | pelunasan melebihi t.sisa | backend/pembelian/layanan.py:181-19415 |
alokasi_salah | alokasi retur bukan bagian dari nota | backend/pembelian/layanan.py:207-26323 |
retur_lebih | retur melebihi jumlah_botol alokasi | backend/pembelian/layanan.py:207-26323 |
retur_kedaluwarsa | barang layak dikembalikan dari batch kedaluwarsa | backend/pembelian/layanan.py:207-26323 |