Lompat ke konten
dokumentasi.id
Commit 32b2830
v2
Star0

Kamu membaca versi lama. Halaman ini hanya bisa dibaca.

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-55. 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.
Fig. 1: Rantai data produk sampai riwayat hargaKategori 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.

Modeldb_tableKolom pentingAturan
Kategorikategorinama (80, unique), aktifurut nama backend/produk/models.py:9-18
Produkproduksku (40, unique), nama (160), kategori, ukuran_ml, kemasan, stok_minimum, aktifstok_minimum default 0, help_text="Dalam satuan botol" backend/produk/models.py:26-43
SatuanProduksatuan_produkproduk, nama (30), isi_botol, aktifunik per (produk, nama), urut isi_botol backend/produk/models.py:56-73
HargaProdukharga_produksatuan, harga (18,2), berlaku_mulai, dibuat_olehunik per (satuan, berlaku_mulai), urut -berlaku_mulai backend/produk/models.py:76-94

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

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-94. Konsekuensinya, harga yang berlaku_mulai-nya di masa depan belum terpakai. backend/tests/test_pembelian.py:144-148 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-65. 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-73.

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-52. 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-25. Hasil yang sama diuji langsung lewat stok_teks pada response produk di backend/tests/test_stok.py:53-56.

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-41. Sebaliknya SatuanUbahSerializer hanya mengizinkan id, nama, dan aktif backend/produk/serializers.py:44-47, sedangkan pembacaan satuan memakai SatuanSerializer dengan harga read-only yang boleh null backend/produk/serializers.py:18-25.

⚠️ isi_botol tidak bisa diubah setelah satuan dibuat karena tidak ada di SatuanUbahSerializer. Perubahan isi dus berarti membuat baris SatuanProduk baru 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-40. 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.
Fig. 2: Tiga status stok dari status_stokStok 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-34. 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-88. 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-63.

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-89. Pencatatan harga itu sendiri hanya satu baris HargaProduk.objects.create yang mengisi satuan, harga, berlaku_mulai, dan dibuat_oleh backend/produk/layanan.py:55-58.

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

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-66 backend/produk/views.py:164-176.

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-105. Perubahan pada keempat model juga dicatat auditlog karena ProdukConfig.ready mendaftarkan Kategori, Produk, SatuanProduk, dan HargaProduk backend/produk/apps.py:7-13.

Endpoint

Router mendaftarkan empat prefix untuk domain ini: kategori, produk, satuan, dan harga backend/config/urls.py:22-26. 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-65. Peran dan kode izin dijelaskan di Autentikasi, 2FA, dan Izin.

MethodPathHandlerCatatan
GET, POST, PATCH/api/kategori/ dan /api/kategori/{id}/KategoriViewSettanpa paginasi, UBAH_PRODUK untuk tulis backend/produk/views.py:33-43
GET/api/produk/ProdukViewSet.listfilter q, kategori, aktif, status backend/produk/views.py:67-88
GET/api/produk/{id}/ProdukViewSet.retrievememakai ProdukSerializer backend/produk/serializers.py:69-105
POST/api/produk/ProdukViewSet.createProdukBuatSerializer backend/produk/serializers.py:111-148
PATCH/api/produk/{id}/ProdukViewSet.partial_updateProdukUbahSerializer, tanpa harga backend/produk/serializers.py:151-154
POST/api/produk/impor/ProdukViewSet.imporunggah xlsx, balasan {"dibuat", "dilewati"} backend/produk/views.py:125-142
GET/api/produk/template-impor/ProdukViewSet.template_imporberkas template-produk.xlsx backend/produk/views.py:144-152
POST, PATCH/api/satuan/ dan /api/satuan/{id}/SatuanViewSettanpa list; satuan dibaca lewat produk backend/produk/views.py:155-161
GET/api/harga/HargaViewSet.listsaringan ?produk=<id> backend/produk/views.py:164-176
POST/api/harga/HargaViewSet.createdibuat_oleh diisi otomatis backend/produk/views.py:164-176

⚠️ HargaViewSet tidak punya update maupun destroy, dan ProdukViewSet tidak punya delete. Harga baru selalu berupa baris HargaProduk tambahan; produk lama dinonaktifkan lewat aktif, bukan dihapus backend/produk/views.py:46-55.

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

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.
Fig. 3: Alur satu request impor produkBerkas 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-103.

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

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-142. Nilai batasnya 2 MB untuk foto dan 5 MB untuk impor backend/config/settings.py:174-175.

⚠️ Impor hanya membuat produk baru. SKU yang sudah ada dilewati tanpa memperbarui harga_botol maupun stok_minimum, jadi mengimpor ulang berkas bukan cara mengubah harga. Harga baru ditambahkan lewat POST /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-53.