Lompat ke konten
dokumentasi.id
Commit 32b2830
v2
Star0

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-57. 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-46. 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-119.

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-18.

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.
Fig. 1: Siklus satu request penjualan sampai nota PDFRequest 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:

EndpointActionService yang dipanggilIzin
POST /api/transaksi/createlayanan.buat_transaksiKELOLA_TRANSAKSI
POST /api/transaksi/{id}/bayar/bayarlayanan.catat_pelunasanKELOLA_TRANSAKSI
GET /api/transaksi/{id}/nota/notanota_pdfKELOLA_TRANSAKSI
POST /api/transaksi/{id}/ajukan-batal/ajukan_batallayanan.ajukan_pembatalanKELOLA_TRANSAKSI
POST /api/transaksi/{id}/setujui-batal/setujui_batallayanan.setujui_pembatalanSETUJUI_PEMBATALAN
POST /api/transaksi/{id}/tolak-batal/tolak_batallayanan.tolak_pembatalanSETUJUI_PEMBATALAN
POST /api/retur/createlayanan.buat_returKELOLA_TRANSAKSI

Pemetaan izin per action dan penerusan validated_data ke service ada di TransaksiViewSet backend/pembelian/views.py:67-135, sedangkan ReturViewSet.create mengumpulkan transaksi, items, alasan, dan metode_pengembalian lalu memanggil layanan.buat_retur backend/pembelian/views.py:182-213.

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-65. 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-111. 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-146 backend/pembelian/models.py:100-135.

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-141. 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-166 backend/stok/layanan.py:47-50.

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-166. 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-201. Nilai uang muka yang menyamai total juga ditolak dengan uang_muka_lebih backend/pembelian/layanan.py:113-146.

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-194. PelunasanSerializer membatasi jumlah minimal 0.01 backend/pembelian/serializers.py:204-207, dan tabel Pembayaran menegakkan pembayaran_positif agar tidak ada baris nol atau negatif backend/pembelian/models.py:138-158.

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-57. 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-89. 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-64. sisa_tagihan dan kunci_idempoten sengaja dikeluarkan dari auditlog karena keduanya turunan atau idempotensi murni backend/pembelian/apps.py:13.

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-135. 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-263. 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-204. 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-302.

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-263. Model Retur menyimpan total, potong_piutang, dan pengembalian_uang sebagai tiga angka terpisah, dan ReturItem menyimpan kondisi per alokasi backend/pembelian/models.py:164-192.

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.
Fig. 2: Pembagian nilai retur menjadi potong piutang dan uang kembaliNilai 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-292. 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-275.

ReturBuatSerializer menerima transaksi, daftar items berisi alokasi, jumlah_botol, dan kondisi, serta alasan dan metode_pengembalian backend/pembelian/serializers.py:220-232.

Pembatalan dan siklus status

Status transaksi hanya punya tiga nilai: selesai, menunggu_batal, dan dibatalkan backend/pembelian/models.py:40-57. 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-178.

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.
Fig. 3: Siklus status TransaksiNota 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-357. Karena persetujuan memakai izin SETUJUI_PEMBATALAN, pengaju dan penyetuju bisa berasal dari peran berbeda backend/pembelian/views.py:67-135.

⚠️ Persetujuan pembatalan hanya mengembalikan barang yang belum_diretur. Barang yang sudah diretur sebagai rusak tetap tidak masuk kembali ke stok, jadi pembatalan tidak menggandakan barang backend/pembelian/layanan.py:295-357.

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-179. 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-36 backend/inti/format.py:10-14.

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-47. Konversi PDF baru diimpor di dalam fungsi, sehingga weasyprint hanya dibutuhkan saat endpoint nota benar-benar dipanggil backend/pembelian/nota.py:10-36.

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-18. Galat validasi serializer memakai bentuk yang sama dengan kode: "validasi".

kodePemicuSumber
produk_nonaktifsatuan atau produk sudah tidak aktifbackend/pembelian/layanan.py:49-65
harga_belum_adasatuan tidak punya harga berlaku hari inibackend/pembelian/layanan.py:49-65
stok_tidak_cukuptotal botol terkunci kurang dari kebutuhanbackend/pembelian/layanan.py:96-111
uang_muka_lebihuang muka pada nota tempo menyamai totalbackend/pembelian/layanan.py:113-146
status_transaksinota tidak berstatus selesai atau tidak sedang menunggu pembatalanbackend/pembelian/layanan.py:169-178
bayar_lebihpelunasan melebihi t.sisabackend/pembelian/layanan.py:181-194
alokasi_salahalokasi retur bukan bagian dari notabackend/pembelian/layanan.py:207-263
retur_lebihretur melebihi jumlah_botol alokasibackend/pembelian/layanan.py:207-263
retur_kedaluwarsabarang layak dikembalikan dari batch kedaluwarsabackend/pembelian/layanan.py:207-263