5
Produk, Satuan, dan Riwayat Harga
Produk di aplikasi ini tidak pernah dihapus. ProdukViewSet hanya menerima get, post, dan patch, dan docstring kelasnya menulis alasannya: produk lama dinonaktifkan supaya riwayat transaksi tetap utuh backend/produk/views.py:46-551. Semua relasi produk memakai on_delete=models.PROTECT, sehingga baris lama tetap sah dirujuk oleh transaksi dan batch.
Rantai datanya bertingkat: satu Produk memiliki banyak SatuanProduk, dan satu SatuanProduk memiliki banyak HargaProduk yang masing-masing berlaku mulai satu tanggal. Halaman ini menjelaskan rantai itu, arti isi_botol, cara harga dipilih untuk satu tanggal, status stok minimum, dan impor massal dari berkas xlsx.
Rantai Produk, Satuan, dan Harga
erDiagram
KATEGORI ||--o{ PRODUK : "mengelompokkan"
PRODUK ||--o{ SATUAN_PRODUK : "punya satuan"
SATUAN_PRODUK ||--o{ HARGA_PRODUK : "riwayat harga"
KATEGORI {
bigint id PK
varchar80 nama UK
bool aktif
}
PRODUK {
bigint id PK
varchar40 sku UK
varchar160 nama
int ukuran_ml
varchar60 kemasan
int stok_minimum
bool aktif
}
SATUAN_PRODUK {
bigint id PK
bigint produk_id FK
varchar30 nama
int isi_botol
bool aktif
}
HARGA_PRODUK {
bigint id PK
bigint satuan_id FK
decimal harga
date berlaku_mulai
bigint dibuat_oleh_id FK
}
style PRODUK fill:#336791,color:#fff
style SATUAN_PRODUK fill:#336791,color:#fff
style HARGA_PRODUK fill:#8b5cf6,color:#fff
style KATEGORI fill:#f59e0b,color:#000
%% fig: Rantai data produk sampai riwayat harga
%% desc: Kategori menaungi produk, produk memiliki banyak satuan, dan setiap satuan menyimpan baris harga per tanggal berlaku.Setiap model punya db_table sendiri, dan PROTECT dipakai agar penghapusan kategori, produk, atau satuan ditolak selama masih ada rujukan.
| Model | db_table | Kolom penting | Aturan |
|---|---|---|---|
Kategori | kategori | nama (80, unique), aktif | urut nama backend/produk/models.py:9-182 |
Produk | produk | sku (40, unique), nama (160), kategori, ukuran_ml, kemasan, stok_minimum, aktif | stok_minimum default 0, help_text="Dalam satuan botol" backend/produk/models.py:26-433 |
SatuanProduk | satuan_produk | produk, nama (30), isi_botol, aktif | unik per (produk, nama), urut isi_botol backend/produk/models.py:56-734 |
HargaProduk | harga_produk | satuan, harga (18,2), berlaku_mulai, dibuat_oleh | unik per (satuan, berlaku_mulai), urut -berlaku_mulai backend/produk/models.py:76-945 |
Harga yang berlaku pada satu tanggal
Harga tidak disimpan sebagai satu kolom di produk atau satuan. Harga adalah baris riwayat, dan harga yang dipakai untuk sebuah tanggal diambil oleh SatuanQuerySet.dengan_harga: subquery ke HargaProduk dengan berlaku_mulai kurang atau sama dengan tanggal itu, diurutkan menurun lalu diambil satu, kemudian di-annotate sebagai harga pada queryset satuan backend/produk/models.py:46-536.
Karena ada constraint unik harga_unik_per_tanggal pada (satuan, berlaku_mulai) dan check harga_tidak_negatif, satu pasangan satuan dan tanggal hanya punya satu harga backend/produk/models.py:76-945. Konsekuensinya, harga yang berlaku_mulai-nya di masa depan belum terpakai. backend/tests/test_pembelian.py:144-1487 merekam harga 9000 mulai tiga hari ke depan, lalu transaksi tetap memakai 3500.00.
Pemakaian harga di jalur penjualan ada di _ambil_satuan, yang memanggil dengan_harga untuk timezone.localdate(), menolak satuan nonaktif atau produk nonaktif dengan kode produk_nonaktif, dan menolak satuan tanpa harga dengan kode harga_belum_ada backend/pembelian/layanan.py:49-658. Alur transaksinya dijelaskan di Transaksi Penjualan, Retur, dan Piutang.
Satuan dan isi_botol
isi_botol menyatakan berapa botol terkandung dalam satu satuan. Nilainya dijaga di dua lapis: MinValueValidator(1) di field dan CheckConstraint isi_botol_positif di database, ditambah constraint unik satuan_unik_per_produk backend/produk/models.py:56-734.
Satuan terbesar dipakai untuk menuliskan stok dalam bentuk dus dan sisa botol. satuan_terbesar memilih isi_botol maksimum, dan mengembalikan (1, "botol") bila produk belum punya satuan backend/produk/layanan.py:43-529. Fungsi stok_teks lalu membagi stok dengan isi_botol dan menulis sisanya, misalnya 2888 botol pada dus isi 24 menjadi 120 dus 8 botol backend/inti/format.py:17-2510. Hasil yang sama diuji langsung lewat stok_teks pada response produk di backend/tests/test_stok.py:53-5611.
Pembuatan satuan lewat API selalu menyertakan harga awal. SatuanBuatSerializer menerima produk, nama, isi_botol, dan harga (write-only, minimal 0); di create ia menyimpan satuan lalu memanggil catat_harga dengan timezone.localdate() dan user dari request backend/produk/serializers.py:28-4112. Sebaliknya SatuanUbahSerializer hanya mengizinkan id, nama, dan aktif backend/produk/serializers.py:44-4713, sedangkan pembacaan satuan memakai SatuanSerializer dengan harga read-only yang boleh null backend/produk/serializers.py:18-2514.
⚠️
isi_botoltidak bisa diubah setelah satuan dibuat karena tidak ada diSatuanUbahSerializer. Perubahan isi dus berarti membuat barisSatuanProdukbaru beserta harganya, bukan menambal baris lama.
Status stok minimum
status_stok memetakan stok tersedia ke tiga nilai teks: habis bila tersedia kurang atau sama dengan 0, menipis bila tersedia kurang atau sama dengan stok_minimum, dan aman untuk sisanya backend/produk/layanan.py:37-4015. Karena stok_minimum dinyatakan dalam botol, ambang ini dibandingkan langsung dengan hasil penjumlahan sisa batch.
graph TD
A["stok_tersedia hasil dengan_stok"] --> B{"tersedia habis?"}
B -->|ya| H["habis"]
B -->|tidak| C{"tersedia di bawah atau sama dengan stok_minimum?"}
C -->|ya| M["menipis"]
C -->|tidak| S["aman"]
style A fill:#8b5cf6,color:#fff
style H fill:#dc2626,color:#fff
style M fill:#f59e0b,color:#000
style S fill:#2d7d46,color:#fff
%% fig: Tiga status stok dari status_stok
%% desc: Stok tersedia dibandingkan dengan nol dan dengan stok_minimum untuk menghasilkan status habis, menipis, atau aman.Angka stok_tersedia bukan kolom tabel, melainkan hasil anotasi dengan_stok. Fungsi itu menjumlahkan sisa batch yang kedaluwarsa-nya belum lewat sebagai stok_tersedia, memisahkan batch kedaluwarsa sebagai stok_kedaluwarsa, dan mengambil tanggal kedaluwarsa terdekat sebagai kedaluwarsa_terdekat backend/produk/layanan.py:15-3416. Batch dan FEFO dibahas di Stok per Batch dan Alokasi FEFO.
ProdukViewSet.get_queryset memakai anotasi itu untuk filter status. Nilai habis memfilter stok_tersedia__lte=0, menipis memfilter stok_tersedia__gt=0 dan stok_tersedia__lte=F("stok_minimum"), dan aman memfilter stok_tersedia__gt=F("stok_minimum"); parameter lain adalah q, kategori, dan aktif=1 backend/produk/views.py:67-8817. Perilaku filter ini diuji dengan stok 10 botol: hitungan menipis menjadi 1, aman menjadi 0, dan status_stok pada hasil daftar berisi menipis di backend/tests/test_stok.py:59-6318.
Membuat produk beserta harga awalnya
buat_produk dijalankan dalam satu transaction.atomic. Ia membuat Produk, selalu membuat satuan botol dengan isi_botol=1 dan mencatat harga_botol mulai hari ini, lalu membuat satuan dus dengan isi_botol=isi_dus dan harga_dus hanya bila keduanya diisi backend/produk/layanan.py:61-8919. Pencatatan harga itu sendiri hanya satu baris HargaProduk.objects.create yang mengisi satuan, harga, berlaku_mulai, dan dibuat_oleh backend/produk/layanan.py:55-5820.
ProdukBuatSerializer adalah pintu masuknya. harga_botol wajib dan write-only dengan minimal 0, isi_dus minimal 2 dan opsional, dan validate menolak kombinasi setengah jadi dengan pesan "Isi dus dan harga dus harus diisi bersamaan."; create meneruskan semuanya ke buat_produk dengan user dari request backend/produk/serializers.py:111-14821.
Harga setelah itu ditambah sebagai baris baru, bukan ditimpa. HargaSerializer menampilkan satuan_nama dan dibuat_oleh_nama sebagai field read-only, dan HargaViewSet hanya menyediakan list serta create: daftar dapat disaring dengan query produk, dan perform_create mengisi dibuat_oleh dari user yang login backend/produk/serializers.py:50-6622 backend/produk/views.py:164-17623.
Response produk menggabungkan semuanya. ProdukSerializer menyertakan satuan bersarang, stok_tersedia, stok_kedaluwarsa, kedaluwarsa_terdekat, stok_teks, status_stok, dan punya_foto, dengan stok_teks serta status_stok dihitung lewat SerializerMethodField backend/produk/serializers.py:69-10524. Perubahan pada keempat model juga dicatat auditlog karena ProdukConfig.ready mendaftarkan Kategori, Produk, SatuanProduk, dan HargaProduk backend/produk/apps.py:7-1325.
Endpoint
Router mendaftarkan empat prefix untuk domain ini: kategori, produk, satuan, dan harga backend/config/urls.py:22-2626. Setiap action memetakan izin di atribut izin, misalnya impor dan template_impor meminta UBAH_PRODUK sementara foto meminta LIHAT_PRODUK backend/produk/views.py:56-6527. Peran dan kode izin dijelaskan di Autentikasi, 2FA, dan Izin.
| Method | Path | Handler | Catatan |
|---|---|---|---|
GET, POST, PATCH | /api/kategori/ dan /api/kategori/{id}/ | KategoriViewSet | tanpa paginasi, UBAH_PRODUK untuk tulis backend/produk/views.py:33-4328 |
GET | /api/produk/ | ProdukViewSet.list | filter q, kategori, aktif, status backend/produk/views.py:67-8817 |
GET | /api/produk/{id}/ | ProdukViewSet.retrieve | memakai ProdukSerializer backend/produk/serializers.py:69-10524 |
POST | /api/produk/ | ProdukViewSet.create | ProdukBuatSerializer backend/produk/serializers.py:111-14821 |
PATCH | /api/produk/{id}/ | ProdukViewSet.partial_update | ProdukUbahSerializer, tanpa harga backend/produk/serializers.py:151-15429 |
POST | /api/produk/impor/ | ProdukViewSet.impor | unggah xlsx, balasan {"dibuat", "dilewati"} backend/produk/views.py:125-14230 |
GET | /api/produk/template-impor/ | ProdukViewSet.template_impor | berkas template-produk.xlsx backend/produk/views.py:144-15231 |
POST, PATCH | /api/satuan/ dan /api/satuan/{id}/ | SatuanViewSet | tanpa list; satuan dibaca lewat produk backend/produk/views.py:155-16132 |
GET | /api/harga/ | HargaViewSet.list | saringan ?produk=<id> backend/produk/views.py:164-17623 |
POST | /api/harga/ | HargaViewSet.create | dibuat_oleh diisi otomatis backend/produk/views.py:164-17623 |
⚠️
HargaViewSettidak punyaupdatemaupundestroy, danProdukViewSettidak punyadelete. Harga baru selalu berupa barisHargaProduktambahan; produk lama dinonaktifkan lewataktif, bukan dihapus backend/produk/views.py:46-551.
Impor massal dari berkas xlsx
Template ditentukan oleh konstanta KOLOM: sku, nama, kategori, ukuran_ml, kemasan, stok_minimum, harga_botol, isi_dus, harga_dus, dengan batas MAKS_BARIS = 5000 backend/produk/impor.py:11-2233. buat_template menulis satu lembar bernama Produk berisi baris kepala itu dan satu baris contoh AMK-600 dengan harga botol 3500 dan dus isi 24 seharga 72000 backend/produk/impor.py:25-3334.
graph LR
U["POST /api/produk/impor/"] --> B["BerkasSerializer dan cek IMPOR_MAKS_BYTE"]
B --> R["impor.baca_baris"]
R -->|galat| E["ValidationError 400"]
R -->|baris valid| S["impor.simpan_baris"]
S --> K["Kategori.objects.get_or_create"]
S --> L["buat_produk"]
L --> H["catat_harga"]
S --> O["hasil dibuat dan dilewati"]
style U fill:#f59e0b,color:#000
style B fill:#f59e0b,color:#000
style E fill:#dc2626,color:#fff
style H fill:#336791,color:#fff
style O fill:#2d7d46,color:#fff
%% fig: Alur satu request impor produk
%% desc: Berkas diperiksa ukurannya, baris dibaca dan divalidasi, lalu setiap SKU baru dibuat beserta satuan dan harga awalnya.baca_baris membuka lembar pertama dengan read_only=True, data_only=True, menormalkan baris kepala menjadi huruf kecil, dan langsung menolak berkas yang kepalanya tidak sama dengan KOLOM dengan pesan "Kolom tidak sesuai template: " diikuti daftar kolomnya. Baris kosong dilewati, dan setiap baris yang bermasalah dicatat sebagai teks galat: "Baris {nomor}: angka tidak valid.", "Baris {nomor}: sku, nama, kategori, dan harga_botol wajib diisi.", "Baris {nomor}: SKU {sku} ganda di berkas.", "Baris {nomor}: harga tidak boleh negatif.", serta "Baris {nomor}: isi_dus dan harga_dus harus diisi bersamaan." Bila baris melewati batas, pembacaan berhenti dengan "Berkas melebihi 5000 baris." backend/produk/impor.py:51-10335.
simpan_baris berjalan dalam satu transaction.atomic dan melewati SKU yang sudah ada, sehingga berkas yang sama aman diimpor ulang. Kategori dibuat bila belum ada lewat Kategori.objects.get_or_create(nama=...), setiap produk baru dibuat dengan buat_produk, dan hasilnya diringkas menjadi {"dibuat": dibuat, "dilewati": len(sudah_ada)} backend/produk/impor.py:106-11936.
Action impor di view memvalidasi BerkasSerializer, menolak berkas di atas settings.IMPOR_MAKS_BYTE dengan pesan "Ukuran berkas maksimal 5 MB.", mengubah kegagalan pembacaan menjadi "Berkas bukan Excel yang valid.", dan mengembalikan galat baris sebagai ValidationError sebelum ada yang disimpan backend/produk/views.py:125-14230. Nilai batasnya 2 MB untuk foto dan 5 MB untuk impor backend/config/settings.py:174-17537.
⚠️ Impor hanya membuat produk baru. SKU yang sudah ada dilewati tanpa memperbarui
harga_botolmaupunstok_minimum, jadi mengimpor ulang berkas bukan cara mengubah harga. Harga baru ditambahkan lewatPOST /api/harga/sebagai baris riwayat baru.
Untuk melihat harga yang benar-benar dipakai hari ini, baca harga pada satuan di response produk: nilainya berasal dari subquery dengan_harga dengan timezone.localdate(), bukan dari baris terakhir yang tersimpan backend/produk/models.py:46-536.