Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/filter-purchase

This commit is contained in:
ragilap
2026-04-01 23:20:15 +07:00
40 changed files with 3138 additions and 368 deletions
+31
View File
@@ -0,0 +1,31 @@
# Farm Stock Attribution Design Note
## Goal
Allow farm-level physical stock to be used directly by kandang-level operations without forcing transfers, while keeping kandang attribution, FIFO-v2 compatibility, traceability, and HPP/COGS intact.
## Core Model
- Physical stock stays on the real `product_warehouse_id` that was consumed or received.
- Kandang attribution comes from the transaction or allocation path, not from `product_warehouses.project_flock_kandang_id`.
- Existing kandang-bound warehouses remain valid for historical and current kandang-only flows.
- Shared farm warehouses must stay shareable; application code must stop silently converting them into kandang-owned warehouses.
## Attribution Rules
- `recording_stocks`: consumer kandang is the parent `recordings.project_flock_kandangs_id`; physical stock source remains `recording_stocks.product_warehouse_id`.
- `recording_depletions`: source kandang is the recording kandang and is stored explicitly for compatibility; physical source remains `source_product_warehouse_id`, destination stock remains `product_warehouse_id`.
- `recording_eggs`: producer kandang is the recording kandang and is stored explicitly for compatibility; physical stock remains `product_warehouse_id`, which may be a farm warehouse.
- `marketing_delivery_products`: outbound kandang attribution comes from active `stock_allocations` to `PROJECT_FLOCK_POPULATION`, `RECORDING_DEPLETION`, or `RECORDING_EGG`, with product-warehouse kandang ownership only as a fallback for historical/non-FIFO rows.
## Reporting and HPP
- Feed and OVK cost attribution should continue to follow recording-level consumption plus FIFO allocations to incoming stock.
- Egg and live-bird sales attribution should be derived from `stock_allocations` back to the originating kandang transactions or populations.
- Queries that filter or group by kandang must use explicit transaction attribution or FIFO allocation provenance, not warehouse ownership, when pooled farm stock is involved.
## Live-Data Safety
- Schema changes are additive and nullable.
- Historical rows are backfilled only when attribution is deterministic from existing rows.
- No FIFO-v2 route-rule behavior is changed unless the current code is only resyncing or constraining allocation metadata around already-created FIFO allocations.
+45
View File
@@ -0,0 +1,45 @@
ID;Kategori;Area;Judul;Tipe;Prioritas;Setup/Precondition;Langkah Uji;Hasil yang Diharapkan
TC-A01;Migrasi dan Keamanan Data;Database;Migrasi aman pada DB tidak kosong;Integration;High;Gunakan snapshot DB staging yang sudah berisi recording, depletion, telur, penjualan, dan closing.;1. Jalankan migrasi 20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql. 2. Inspect schema hasil migrasi.;Kolom recording_depletions.source_project_flock_kandang_id dan recording_eggs.project_flock_kandang_id tersedia dan nullable, index dan FK tersedia, tidak ada data historis yang terhapus atau berubah destruktif.
TC-A02;Migrasi dan Keamanan Data;Database;Backfill deterministik berjalan;Integration;High;Ada data historis recording dengan recordings.project_flock_kandangs_id yang valid.;1. Query recording_depletions dan recording_eggs yang lama. 2. Bandingkan dengan kandang pada parent recording.;source_project_flock_kandang_id dan project_flock_kandang_id terisi sama dengan kandang parent recording untuk row yang sebelumnya null.
TC-A03;Migrasi dan Keamanan Data;Reporting;Report historis kandang-only tidak berubah;Regression;High;Gunakan snapshot yang hanya memiliki data stok historis milik kandang, tanpa pooled stock farm-level.;1. Jalankan closing/report/HPP sebelum deploy. 2. Jalankan lagi sesudah deploy pada snapshot yang sama. 3. Bandingkan hasil.;Total dan hasil report tetap sama untuk skenario historis kandang-only.
TC-B01;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok masuk ke product_warehouse level farm, tidak perlu transfer paksa ke kandang, FIFO/HPP purchase tetap benar.
TC-B02;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang kandang;Regression;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Kandang A1. 2. Approve dan receive purchase.;Stok masuk ke gudang kandang dan perilaku tetap sama seperti flow lama.
TC-B03;Purchase dan Warehouse;Purchase;Purchase OVK langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk OVK A.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok OVK masuk ke gudang farm dan bisa dipakai kemudian pada recording.
TC-B04;Purchase dan Warehouse;Product Warehouse;Gudang farm shared tidak diubah diam-diam menjadi milik kandang;Regression;High;Sudah ada row product_warehouse level farm untuk Pakan Starter di Gudang Farm A.;1. Trigger flow yang memanggil ensure/find product warehouse untuk produk yang sama. 2. Inspect row existing.;Row farm-level tetap farm-level, project_flock_kandang_id tidak dibackfill diam-diam, row khusus kandang dibuat terpisah bila memang diperlukan.
TC-C01;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang kandang;Regression;High;Stok pakan tersedia di Gudang Kandang A1.;1. Buka recording untuk Kandang A1. 2. Pilih pakan dari gudang kandang. 3. Submit dan approve.;Recording berhasil, stok keluar dari product_warehouse kandang, atribusi kandang tetap A1, HPP pemakaian muncul di closing/HPP A1.
TC-C02;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang farm;UAT;High;Stok pakan hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok pakan farm-level. 3. Submit dan approve.;Recording berhasil tanpa transfer ke kandang, stok fisik berkurang dari gudang farm, usage/HPP tetap teratribusi ke Kandang A1, closing farm dan kandang tetap bisa dihitung.
TC-C03;Recording Stock Consumption;Recording;Recording kandang memakai OVK dari gudang farm;UAT;High;Stok OVK hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok OVK farm-level. 3. Submit dan approve.;Stok OVK keluar dari gudang farm dan biaya pemakaian teratribusi ke kandang yang dipilih.
TC-C04;Recording Stock Consumption;Frontend Recording;Selector recording menampilkan opsi stok farm dan kandang dengan jelas;UI Regression;Medium;Produk yang sama tersedia di Gudang Farm A dan Gudang Kandang A1.;1. Buka form recording untuk A1. 2. Buka selector pakan.;Kedua opsi terlihat, label membedakan gudang atau scope dengan jelas, farm stock tidak tersembunyi secara salah.
TC-C05;Recording Stock Consumption;Recording;Recording A1 tidak boleh memakai stok kandang A2;Negative;High;Pakan Starter tersedia di Gudang Kandang A2.;1. Buka recording untuk A1. 2. Periksa opsi stok yang bisa dipilih.;Opsi Gudang Kandang A2 tidak bisa dipilih, stok farm tetap bisa dipilih.
TC-C06;Recording Stock Consumption;Recording;Perilaku pending stock dan usage lama tetap berjalan;Regression;Medium;Tidak ada setup khusus selain data recording yang valid.;1. Buat usage stock. 2. Buka kembali halaman edit dan detail.;Tampilan dan perhitungan pending atau usage tetap benar, tidak ada regresi pada route FIFO-v2.
TC-D01;Recording Telur dan Atribusi;Recording;Recording telur ke gudang kandang tetap berjalan;Regression;High;Kandang A1 aktif dan gudang telur kandang tersedia.;1. Record telur untuk A1 ke Gudang Kandang A1. 2. Approve.;Stok telur di gudang kandang bertambah dan asal kandang tetap A1.
TC-D02;Recording Telur dan Atribusi;Recording;Recording telur di kandang menyimpan stok ke gudang farm;UAT;High;Egg product warehouse tersedia di Gudang Farm A.;1. Record telur untuk A1. 2. Pilih Gudang Farm A sebagai gudang telur. 3. Submit dan approve.;Stok telur fisik masuk ke gudang farm, recording_eggs.project_flock_kandang_id bernilai A1, tidak ada transfer paksa ke kandang.
TC-D03;Recording Telur dan Atribusi;Reporting;Stok telur pooled di farm tetap punya jejak asal kandang;Integration;High;A1 record 100 telur ke gudang farm dan A2 record 150 telur ke gudang farm yang sama.;1. Inspect row telur yang tersimpan. 2. Inspect hasil costing atau report setelahnya.;Stok fisik pooled di gudang farm, tetapi asal kandang tetap bisa dibedakan per row atau allocation, HPP per kandang tetap dapat dihitung.
TC-D04;Recording Telur dan Atribusi;Recording Detail;Known gap pada detail recording dipahami;Known Limitation;Low;Sudah menjalankan TC-D02.;1. Buka detail recording setelah transaksi telur ke gudang farm.;Logika bisnis tetap berjalan, tetapi detail API atau UI mungkin belum menampilkan egg-origin secara eksplisit karena detail DTO belum diperluas.
TC-E01;Depletion dan Atribusi Populasi;Recording;Depletion dari gudang ayam milik kandang normal;Regression;High;A1 memiliki populasi ayam di gudang kandang.;1. Buat depletion. 2. Approve.;Depletion berhasil, alokasi populasi ter-resolve ke A1, HPP atau usage tetap benar.
TC-E02;Depletion dan Atribusi Populasi;Recording;Depletion dari sumber ayam fisik farm-level dengan source kandang A1;UAT;High;Stok ayam secara fisik ada di gudang farm dan punya jejak sumber ke A1.;1. Buat depletion untuk A1. 2. Gunakan path source atau farm-level yang didukung backend. 3. Approve.;source_product_warehouse_id menunjuk ke sumber fisik yang benar, source_project_flock_kandang_id bernilai A1, alokasi populasi berhasil tanpa mengasumsikan gudang fisik milik A1.
TC-E03;Depletion dan Atribusi Populasi;Recording;Depletion gagal bila sumber populasi tidak dapat diatribusikan;Negative;High;Buat kasus stok ayam farm-level tanpa source kandang yang valid.;1. Coba approve depletion.;Backend menolak dengan error yang jelas dan tidak ada silent misattribution.
TC-F01;Marketing dan Penjualan;Sales Order;Sales order dari gudang kandang tetap berjalan;Regression;High;Stok produk tersedia di Gudang Kandang A1.;1. Buat SO dari Gudang Kandang A1. 2. Lakukan delivery.;Perilaku lama tetap berjalan normal.
TC-F02;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur;UAT;High;Stok telur farm-level tersedia dan berasal dari A1.;1. Buat SO menggunakan Gudang Farm A. 2. Lakukan delivery.;SO dan DO berhasil, stok fisik berkurang dari gudang farm, HPP dan COGS telur tetap teratribusi ke kandang penghasil melalui allocation.
TC-F03;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur pooled A1 dan A2;Integration;High;Stok telur pooled tersedia di gudang farm dari A1 dan A2.;1. Buat penjualan. 2. Lakukan delivery. 3. Inspect closing atau report.;Stok fisik berkurang sekali dari gudang farm, revenue dan HPP terbagi benar ke A1 dan A2, tidak bergantung pada pw.project_flock_kandang_id.
TC-F04;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk ayam atau culling;UAT;High;Stok ayam atau culling farm-level tersedia dengan jejak sumber dari A1 dan A2.;1. Buat SO dari gudang farm. 2. Buat DO dan approve.;allocatePopulationForMarketingDelivery menurunkan atribusi kandang dari source groups atau allocation, tidak gagal karena gudang jual tidak punya project_flock_kandang_id, HPP dan COGS teratribusi ke kandang sumber.
TC-F05;Marketing dan Penjualan;Frontend Marketing;UI sales menampilkan semantik Gudang Fisik;UI Regression;Medium;Tidak ada setup khusus selain akses ke form SO.;1. Buka form SO. 2. Periksa label selector gudang dan label tabel produk.;UI menggunakan label Gudang Fisik, bukan Kandang yang menyesatkan, dan label produk memuat detail produk serta gudang atau scope.
TC-F06;Marketing dan Penjualan;Delivery Order;Layar delivery order tetap kompatibel;Regression;Medium;Sudah ada SO dari gudang farm.;1. Lakukan delivery untuk SO farm-level. 2. Periksa tabel dan detail DO.;Tidak ada masalah payload, gudang fisik tampil dengan benar, dan tidak ada kebingungan akibat wording lama berbasis kandang.
TC-G01;Report, Closing, dan HPP;Daily Marketing Report;Daily marketing report untuk penjualan telur farm-level;UAT;Medium;Sudah menjalankan TC-F02.;1. Jalankan daily marketing report. 2. Uji export.;Row muncul pada gudang fisik yang benar, report tidak menyiratkan gudang sama dengan kandang, export berjalan.
TC-G02;Report, Closing, dan HPP;Closing Sales;Closing sales untuk penjualan pooled farm-level;UAT;High;Ada penjualan pooled telur atau ayam dari gudang farm.;1. Buka closing sales.;Penjualan bisa tampil teratribusi per kandang, label menunjukkan Kandang Atribusi, HPP dan revenue tetap benar secara matematis.
TC-G03;Report, Closing, dan HPP;HPP per Kandang;HPP per kandang mencakup konsumsi pakan atau OVK dari gudang farm;UAT;High;A1 sudah memakai pakan atau OVK dari gudang farm.;1. Jalankan report HPP per kandang.;Biaya usage muncul di A1 dan tidak hilang walaupun gudang fisiknya level farm.
TC-G04;Report, Closing, dan HPP;Closing Sapronak;Outgoing sapronak menampilkan gudang fisik dengan benar;UI Regression;Medium;Ada data outgoing sapronak yang valid.;1. Buka tabel closing outgoing sapronak.;Header jelas menunjukkan Gudang Asal (Fisik) dan Gudang Tujuan (Fisik).
TC-G05;Report, Closing, dan HPP;Compatibility;Data historis kandang-owned dan pooled data baru dapat coexist;Regression;High;Dalam satu date range ada transaksi lama kandang-owned dan transaksi baru pooled farm-level.;1. Jalankan closing. 2. Jalankan report. 3. Jalankan HPP.;Kedua jenis data diproses dengan benar, tidak ada double count dan tidak ada atribusi yang hilang.
TC-H01;FIFO-v2 dan Integritas Allocation;FIFO-v2;Kontrak FIFO-v2 tidak berubah;Integration;High;Gunakan data uji yang mencakup recording stock, depletion, egg, dan marketing.;1. Verifikasi route FIFO untuk RECORDING_STOCK_OUT, RECORDING_DEPLETION_OUT, RECORDING_DEPLETION_IN, RECORDING_EGG_IN, dan MARKETING_OUT. 2. Bandingkan dengan RFC.md dan seed config FIFO-v2.;Tidak ada perubahan semantik route yang tidak disengaja.
TC-H02;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk pakan dari gudang farm;Integration;High;Sudah menjalankan TC-C02.;1. Inspect stock_allocations setelah transaksi.;Allocation consume terbentuk dengan benar dan tidak ada row allocation yatim atau rusak.
TC-H03;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk penjualan telur pooled;Integration;High;Sudah menjalankan TC-F03.;1. Inspect stock_allocations. 2. Inspect row atribusi turunannya.;Allocation mendukung atribusi HPP kembali ke kandang sumber.
TC-H04;FIFO-v2 dan Integritas Allocation;Population Allocation;Population allocation tetap konsisten untuk penjualan ayam pooled;Integration;High;Sudah menjalankan TC-F04.;1. Inspect population allocations.;Penggunaan kandang sumber teralokasi dengan benar dan tidak fallback ke atribusi null saat source tersedia.
TC-I01;Negative dan Guard Cases;Recording;Recording dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari pemakaian yang diinput.;1. Buat recording dengan qty melebihi stok. 2. Submit atau approve.;Muncul validation atau business error dan tidak ada korupsi parsial.
TC-I02;Negative dan Guard Cases;Marketing;Marketing dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari qty penjualan.;1. Buat SO atau DO dengan qty melebihi stok. 2. Submit atau approve.;Delivery atau approval diblok dan stok tetap konsisten.
TC-I03;Negative dan Guard Cases;Frontend Selector;Opsi produk sama di gudang berbeda tidak salah terpilih;UI Regression;Medium;Produk yang sama tersedia di gudang farm dan gudang kandang.;1. Pilih masing-masing opsi secara eksplisit di UI. 2. Save. 3. Buka kembali edit atau detail.;Opsi yang terpilih jelas dan tetap stabil setelah save atau edit.
TC-I04;Negative dan Guard Cases;Product Warehouse;Row gudang shared tidak diatribusikan ulang oleh flow maintenance;Regression;High;Ada row shared farm warehouse yang sudah aktif.;1. Jalankan flow yang menyentuh logic ensure/find product warehouse. 2. Cek ulang row farm shared.;Tidak ada mutasi diam-diam pada project_flock_kandang_id.
TC-J01;Regression Frontend dan UX;Recording Form;Form recording menampilkan opsi stok farm dan kandang hanya dalam scope farm yang sama;UI Regression;Medium;Ada stok di gudang farm, gudang kandang saat ini, dan gudang kandang lain.;1. Buka form recording untuk kandang tertentu. 2. Periksa opsi stock selector.;Gudang farm dan gudang kandang saat ini terlihat, gudang kandang lain tersembunyi.
TC-J02;Regression Frontend dan UX;Recording Form;Selector recording telur mengizinkan gudang farm;UI Regression;Medium;Egg warehouse tersedia di gudang farm.;1. Buka form recording telur. 2. Buka selector tujuan telur.;Gudang farm terlihat sebagai opsi tujuan telur.
TC-J03;Regression Frontend dan UX;Sales Form;Form sales memakai semantik gudang secara konsisten;UI Regression;Medium;Akses ke halaman marketing tersedia.;1. Buka form sales. 2. Periksa label selector dan summary table.;Label menggunakan Gudang Fisik secara konsisten dan tidak ada wording Kandang yang menyesatkan untuk stok fisik.
TC-J04;Regression Frontend dan UX;Marketing Modal;Modal list marketing menampilkan label gudang fisik;UI Regression;Low;Akses ke modal product list tersedia.;1. Buka modal product list di marketing.;Kolom menampilkan label Gudang Fisik.
TC-K01;Known Limitation;Recording Detail;Detail recording belum menampilkan source atau origin attribution baru;Known Limitation;Low;Sudah ada recording telur farm-level dan depletion dengan source attribution.;1. Buat transaksi. 2. Buka detail recording.;Transaksi berjalan dan atribusi tersimpan di DB, tetapi detail API atau UI mungkin belum menampilkan field source atau origin tersebut
1 ID Kategori Area Judul Tipe Prioritas Setup/Precondition Langkah Uji Hasil yang Diharapkan
2 TC-A01 Migrasi dan Keamanan Data Database Migrasi aman pada DB tidak kosong Integration High Gunakan snapshot DB staging yang sudah berisi recording, depletion, telur, penjualan, dan closing. 1. Jalankan migrasi 20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql. 2. Inspect schema hasil migrasi. Kolom recording_depletions.source_project_flock_kandang_id dan recording_eggs.project_flock_kandang_id tersedia dan nullable, index dan FK tersedia, tidak ada data historis yang terhapus atau berubah destruktif.
3 TC-A02 Migrasi dan Keamanan Data Database Backfill deterministik berjalan Integration High Ada data historis recording dengan recordings.project_flock_kandangs_id yang valid. 1. Query recording_depletions dan recording_eggs yang lama. 2. Bandingkan dengan kandang pada parent recording. source_project_flock_kandang_id dan project_flock_kandang_id terisi sama dengan kandang parent recording untuk row yang sebelumnya null.
4 TC-A03 Migrasi dan Keamanan Data Reporting Report historis kandang-only tidak berubah Regression High Gunakan snapshot yang hanya memiliki data stok historis milik kandang, tanpa pooled stock farm-level. 1. Jalankan closing/report/HPP sebelum deploy. 2. Jalankan lagi sesudah deploy pada snapshot yang sama. 3. Bandingkan hasil. Total dan hasil report tetap sama untuk skenario historis kandang-only.
5 TC-B01 Purchase dan Warehouse Purchase Purchase pakan langsung ke gudang farm UAT High Tersedia PO atau purchase request untuk produk Pakan Starter. 1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase. Stok masuk ke product_warehouse level farm, tidak perlu transfer paksa ke kandang, FIFO/HPP purchase tetap benar.
6 TC-B02 Purchase dan Warehouse Purchase Purchase pakan langsung ke gudang kandang Regression High Tersedia PO atau purchase request untuk produk Pakan Starter. 1. Buat purchase ke Gudang Kandang A1. 2. Approve dan receive purchase. Stok masuk ke gudang kandang dan perilaku tetap sama seperti flow lama.
7 TC-B03 Purchase dan Warehouse Purchase Purchase OVK langsung ke gudang farm UAT High Tersedia PO atau purchase request untuk produk OVK A. 1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase. Stok OVK masuk ke gudang farm dan bisa dipakai kemudian pada recording.
8 TC-B04 Purchase dan Warehouse Product Warehouse Gudang farm shared tidak diubah diam-diam menjadi milik kandang Regression High Sudah ada row product_warehouse level farm untuk Pakan Starter di Gudang Farm A. 1. Trigger flow yang memanggil ensure/find product warehouse untuk produk yang sama. 2. Inspect row existing. Row farm-level tetap farm-level, project_flock_kandang_id tidak dibackfill diam-diam, row khusus kandang dibuat terpisah bila memang diperlukan.
9 TC-C01 Recording Stock Consumption Recording Recording kandang memakai pakan dari gudang kandang Regression High Stok pakan tersedia di Gudang Kandang A1. 1. Buka recording untuk Kandang A1. 2. Pilih pakan dari gudang kandang. 3. Submit dan approve. Recording berhasil, stok keluar dari product_warehouse kandang, atribusi kandang tetap A1, HPP pemakaian muncul di closing/HPP A1.
10 TC-C02 Recording Stock Consumption Recording Recording kandang memakai pakan dari gudang farm UAT High Stok pakan hanya tersedia di Gudang Farm A. 1. Buka recording untuk Kandang A1. 2. Pilih stok pakan farm-level. 3. Submit dan approve. Recording berhasil tanpa transfer ke kandang, stok fisik berkurang dari gudang farm, usage/HPP tetap teratribusi ke Kandang A1, closing farm dan kandang tetap bisa dihitung.
11 TC-C03 Recording Stock Consumption Recording Recording kandang memakai OVK dari gudang farm UAT High Stok OVK hanya tersedia di Gudang Farm A. 1. Buka recording untuk Kandang A1. 2. Pilih stok OVK farm-level. 3. Submit dan approve. Stok OVK keluar dari gudang farm dan biaya pemakaian teratribusi ke kandang yang dipilih.
12 TC-C04 Recording Stock Consumption Frontend Recording Selector recording menampilkan opsi stok farm dan kandang dengan jelas UI Regression Medium Produk yang sama tersedia di Gudang Farm A dan Gudang Kandang A1. 1. Buka form recording untuk A1. 2. Buka selector pakan. Kedua opsi terlihat, label membedakan gudang atau scope dengan jelas, farm stock tidak tersembunyi secara salah.
13 TC-C05 Recording Stock Consumption Recording Recording A1 tidak boleh memakai stok kandang A2 Negative High Pakan Starter tersedia di Gudang Kandang A2. 1. Buka recording untuk A1. 2. Periksa opsi stok yang bisa dipilih. Opsi Gudang Kandang A2 tidak bisa dipilih, stok farm tetap bisa dipilih.
14 TC-C06 Recording Stock Consumption Recording Perilaku pending stock dan usage lama tetap berjalan Regression Medium Tidak ada setup khusus selain data recording yang valid. 1. Buat usage stock. 2. Buka kembali halaman edit dan detail. Tampilan dan perhitungan pending atau usage tetap benar, tidak ada regresi pada route FIFO-v2.
15 TC-D01 Recording Telur dan Atribusi Recording Recording telur ke gudang kandang tetap berjalan Regression High Kandang A1 aktif dan gudang telur kandang tersedia. 1. Record telur untuk A1 ke Gudang Kandang A1. 2. Approve. Stok telur di gudang kandang bertambah dan asal kandang tetap A1.
16 TC-D02 Recording Telur dan Atribusi Recording Recording telur di kandang menyimpan stok ke gudang farm UAT High Egg product warehouse tersedia di Gudang Farm A. 1. Record telur untuk A1. 2. Pilih Gudang Farm A sebagai gudang telur. 3. Submit dan approve. Stok telur fisik masuk ke gudang farm, recording_eggs.project_flock_kandang_id bernilai A1, tidak ada transfer paksa ke kandang.
17 TC-D03 Recording Telur dan Atribusi Reporting Stok telur pooled di farm tetap punya jejak asal kandang Integration High A1 record 100 telur ke gudang farm dan A2 record 150 telur ke gudang farm yang sama. 1. Inspect row telur yang tersimpan. 2. Inspect hasil costing atau report setelahnya. Stok fisik pooled di gudang farm, tetapi asal kandang tetap bisa dibedakan per row atau allocation, HPP per kandang tetap dapat dihitung.
18 TC-D04 Recording Telur dan Atribusi Recording Detail Known gap pada detail recording dipahami Known Limitation Low Sudah menjalankan TC-D02. 1. Buka detail recording setelah transaksi telur ke gudang farm. Logika bisnis tetap berjalan, tetapi detail API atau UI mungkin belum menampilkan egg-origin secara eksplisit karena detail DTO belum diperluas.
19 TC-E01 Depletion dan Atribusi Populasi Recording Depletion dari gudang ayam milik kandang normal Regression High A1 memiliki populasi ayam di gudang kandang. 1. Buat depletion. 2. Approve. Depletion berhasil, alokasi populasi ter-resolve ke A1, HPP atau usage tetap benar.
20 TC-E02 Depletion dan Atribusi Populasi Recording Depletion dari sumber ayam fisik farm-level dengan source kandang A1 UAT High Stok ayam secara fisik ada di gudang farm dan punya jejak sumber ke A1. 1. Buat depletion untuk A1. 2. Gunakan path source atau farm-level yang didukung backend. 3. Approve. source_product_warehouse_id menunjuk ke sumber fisik yang benar, source_project_flock_kandang_id bernilai A1, alokasi populasi berhasil tanpa mengasumsikan gudang fisik milik A1.
21 TC-E03 Depletion dan Atribusi Populasi Recording Depletion gagal bila sumber populasi tidak dapat diatribusikan Negative High Buat kasus stok ayam farm-level tanpa source kandang yang valid. 1. Coba approve depletion. Backend menolak dengan error yang jelas dan tidak ada silent misattribution.
22 TC-F01 Marketing dan Penjualan Sales Order Sales order dari gudang kandang tetap berjalan Regression High Stok produk tersedia di Gudang Kandang A1. 1. Buat SO dari Gudang Kandang A1. 2. Lakukan delivery. Perilaku lama tetap berjalan normal.
23 TC-F02 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk telur UAT High Stok telur farm-level tersedia dan berasal dari A1. 1. Buat SO menggunakan Gudang Farm A. 2. Lakukan delivery. SO dan DO berhasil, stok fisik berkurang dari gudang farm, HPP dan COGS telur tetap teratribusi ke kandang penghasil melalui allocation.
24 TC-F03 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk telur pooled A1 dan A2 Integration High Stok telur pooled tersedia di gudang farm dari A1 dan A2. 1. Buat penjualan. 2. Lakukan delivery. 3. Inspect closing atau report. Stok fisik berkurang sekali dari gudang farm, revenue dan HPP terbagi benar ke A1 dan A2, tidak bergantung pada pw.project_flock_kandang_id.
25 TC-F04 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk ayam atau culling UAT High Stok ayam atau culling farm-level tersedia dengan jejak sumber dari A1 dan A2. 1. Buat SO dari gudang farm. 2. Buat DO dan approve. allocatePopulationForMarketingDelivery menurunkan atribusi kandang dari source groups atau allocation, tidak gagal karena gudang jual tidak punya project_flock_kandang_id, HPP dan COGS teratribusi ke kandang sumber.
26 TC-F05 Marketing dan Penjualan Frontend Marketing UI sales menampilkan semantik Gudang Fisik UI Regression Medium Tidak ada setup khusus selain akses ke form SO. 1. Buka form SO. 2. Periksa label selector gudang dan label tabel produk. UI menggunakan label Gudang Fisik, bukan Kandang yang menyesatkan, dan label produk memuat detail produk serta gudang atau scope.
27 TC-F06 Marketing dan Penjualan Delivery Order Layar delivery order tetap kompatibel Regression Medium Sudah ada SO dari gudang farm. 1. Lakukan delivery untuk SO farm-level. 2. Periksa tabel dan detail DO. Tidak ada masalah payload, gudang fisik tampil dengan benar, dan tidak ada kebingungan akibat wording lama berbasis kandang.
28 TC-G01 Report, Closing, dan HPP Daily Marketing Report Daily marketing report untuk penjualan telur farm-level UAT Medium Sudah menjalankan TC-F02. 1. Jalankan daily marketing report. 2. Uji export. Row muncul pada gudang fisik yang benar, report tidak menyiratkan gudang sama dengan kandang, export berjalan.
29 TC-G02 Report, Closing, dan HPP Closing Sales Closing sales untuk penjualan pooled farm-level UAT High Ada penjualan pooled telur atau ayam dari gudang farm. 1. Buka closing sales. Penjualan bisa tampil teratribusi per kandang, label menunjukkan Kandang Atribusi, HPP dan revenue tetap benar secara matematis.
30 TC-G03 Report, Closing, dan HPP HPP per Kandang HPP per kandang mencakup konsumsi pakan atau OVK dari gudang farm UAT High A1 sudah memakai pakan atau OVK dari gudang farm. 1. Jalankan report HPP per kandang. Biaya usage muncul di A1 dan tidak hilang walaupun gudang fisiknya level farm.
31 TC-G04 Report, Closing, dan HPP Closing Sapronak Outgoing sapronak menampilkan gudang fisik dengan benar UI Regression Medium Ada data outgoing sapronak yang valid. 1. Buka tabel closing outgoing sapronak. Header jelas menunjukkan Gudang Asal (Fisik) dan Gudang Tujuan (Fisik).
32 TC-G05 Report, Closing, dan HPP Compatibility Data historis kandang-owned dan pooled data baru dapat coexist Regression High Dalam satu date range ada transaksi lama kandang-owned dan transaksi baru pooled farm-level. 1. Jalankan closing. 2. Jalankan report. 3. Jalankan HPP. Kedua jenis data diproses dengan benar, tidak ada double count dan tidak ada atribusi yang hilang.
33 TC-H01 FIFO-v2 dan Integritas Allocation FIFO-v2 Kontrak FIFO-v2 tidak berubah Integration High Gunakan data uji yang mencakup recording stock, depletion, egg, dan marketing. 1. Verifikasi route FIFO untuk RECORDING_STOCK_OUT, RECORDING_DEPLETION_OUT, RECORDING_DEPLETION_IN, RECORDING_EGG_IN, dan MARKETING_OUT. 2. Bandingkan dengan RFC.md dan seed config FIFO-v2. Tidak ada perubahan semantik route yang tidak disengaja.
34 TC-H02 FIFO-v2 dan Integritas Allocation Stock Allocation Stock allocation tetap konsisten untuk pakan dari gudang farm Integration High Sudah menjalankan TC-C02. 1. Inspect stock_allocations setelah transaksi. Allocation consume terbentuk dengan benar dan tidak ada row allocation yatim atau rusak.
35 TC-H03 FIFO-v2 dan Integritas Allocation Stock Allocation Stock allocation tetap konsisten untuk penjualan telur pooled Integration High Sudah menjalankan TC-F03. 1. Inspect stock_allocations. 2. Inspect row atribusi turunannya. Allocation mendukung atribusi HPP kembali ke kandang sumber.
36 TC-H04 FIFO-v2 dan Integritas Allocation Population Allocation Population allocation tetap konsisten untuk penjualan ayam pooled Integration High Sudah menjalankan TC-F04. 1. Inspect population allocations. Penggunaan kandang sumber teralokasi dengan benar dan tidak fallback ke atribusi null saat source tersedia.
37 TC-I01 Negative dan Guard Cases Recording Recording dari stok farm-level dengan qty tidak cukup Negative High Stok farm-level tersedia tetapi qty lebih kecil dari pemakaian yang diinput. 1. Buat recording dengan qty melebihi stok. 2. Submit atau approve. Muncul validation atau business error dan tidak ada korupsi parsial.
38 TC-I02 Negative dan Guard Cases Marketing Marketing dari stok farm-level dengan qty tidak cukup Negative High Stok farm-level tersedia tetapi qty lebih kecil dari qty penjualan. 1. Buat SO atau DO dengan qty melebihi stok. 2. Submit atau approve. Delivery atau approval diblok dan stok tetap konsisten.
39 TC-I03 Negative dan Guard Cases Frontend Selector Opsi produk sama di gudang berbeda tidak salah terpilih UI Regression Medium Produk yang sama tersedia di gudang farm dan gudang kandang. 1. Pilih masing-masing opsi secara eksplisit di UI. 2. Save. 3. Buka kembali edit atau detail. Opsi yang terpilih jelas dan tetap stabil setelah save atau edit.
40 TC-I04 Negative dan Guard Cases Product Warehouse Row gudang shared tidak diatribusikan ulang oleh flow maintenance Regression High Ada row shared farm warehouse yang sudah aktif. 1. Jalankan flow yang menyentuh logic ensure/find product warehouse. 2. Cek ulang row farm shared. Tidak ada mutasi diam-diam pada project_flock_kandang_id.
41 TC-J01 Regression Frontend dan UX Recording Form Form recording menampilkan opsi stok farm dan kandang hanya dalam scope farm yang sama UI Regression Medium Ada stok di gudang farm, gudang kandang saat ini, dan gudang kandang lain. 1. Buka form recording untuk kandang tertentu. 2. Periksa opsi stock selector. Gudang farm dan gudang kandang saat ini terlihat, gudang kandang lain tersembunyi.
42 TC-J02 Regression Frontend dan UX Recording Form Selector recording telur mengizinkan gudang farm UI Regression Medium Egg warehouse tersedia di gudang farm. 1. Buka form recording telur. 2. Buka selector tujuan telur. Gudang farm terlihat sebagai opsi tujuan telur.
43 TC-J03 Regression Frontend dan UX Sales Form Form sales memakai semantik gudang secara konsisten UI Regression Medium Akses ke halaman marketing tersedia. 1. Buka form sales. 2. Periksa label selector dan summary table. Label menggunakan Gudang Fisik secara konsisten dan tidak ada wording Kandang yang menyesatkan untuk stok fisik.
44 TC-J04 Regression Frontend dan UX Marketing Modal Modal list marketing menampilkan label gudang fisik UI Regression Low Akses ke modal product list tersedia. 1. Buka modal product list di marketing. Kolom menampilkan label Gudang Fisik.
45 TC-K01 Known Limitation Recording Detail Detail recording belum menampilkan source atau origin attribution baru Known Limitation Low Sudah ada recording telur farm-level dan depletion dengan source attribution. 1. Buat transaksi. 2. Buka detail recording. Transaksi berjalan dan atribusi tersimpan di DB, tetapi detail API atau UI mungkin belum menampilkan field source atau origin tersebut
Binary file not shown.
@@ -0,0 +1,224 @@
package repository
import (
"fmt"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type MarketingDeliveryAttributionRow struct {
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProjectFlockID uint `gorm:"column:project_flock_id"`
ProjectFlockCategory string `gorm:"column:project_flock_category"`
AllocatedQty float64 `gorm:"column:allocated_qty"`
}
func MarketingDeliveryAttributionRowsQuery(db *gorm.DB) *gorm.DB {
sql := `
WITH mapped AS (
SELECT
sa.usable_id AS marketing_delivery_product_id,
pc.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN project_flock_populations pfp
ON pfp.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, pc.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN recording_eggs re
ON re.id = sa.stockable_id
AND sa.stockable_type = ?
LEFT JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN recording_depletions rd
ON rd.id = sa.stockable_id
AND sa.stockable_type = ?
LEFT JOIN recordings r ON r.id = rd.recording_id
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
pi.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN purchase_items pi
ON pi.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_flock_kandangs pfk ON pfk.id = pi.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND pi.project_flock_kandang_id IS NOT NULL
GROUP BY sa.usable_id, pi.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
source_pw.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN stock_transfer_details std
ON std.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id
JOIN project_flock_kandangs pfk ON pfk.id = source_pw.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND source_pw.project_flock_kandang_id IS NOT NULL
GROUP BY sa.usable_id, source_pw.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN laying_transfer_targets ltt
ON ltt.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_flock_kandangs pfk ON pfk.id = ltt.target_project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, ltt.target_project_flock_kandang_id, pfk.project_flock_id, pf.category
)
SELECT
src.marketing_delivery_product_id,
src.project_flock_kandang_id,
src.project_flock_id,
src.project_flock_category,
SUM(src.allocated_qty) AS allocated_qty
FROM (
SELECT
mapped.marketing_delivery_product_id,
mapped.project_flock_kandang_id,
mapped.project_flock_id,
mapped.project_flock_category,
mapped.allocated_qty
FROM mapped
UNION ALL
SELECT
mdp.id AS marketing_delivery_product_id,
pw.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
COALESCE(mdp.usage_qty, 0) AS allocated_qty
FROM marketing_delivery_products mdp
JOIN marketing_products mp ON mp.id = mdp.marketing_product_id
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
LEFT JOIN mapped ON mapped.marketing_delivery_product_id = mdp.id
WHERE mapped.marketing_delivery_product_id IS NULL
AND pw.project_flock_kandang_id IS NOT NULL
AND COALESCE(mdp.usage_qty, 0) > 0
) src
GROUP BY
src.marketing_delivery_product_id,
src.project_flock_kandang_id,
src.project_flock_id,
src.project_flock_category
`
return db.Raw(
sql,
fifo.StockableKeyProjectFlockPopulation.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyRecordingDepletion.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyStockTransferIn.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyTransferToLayingIn.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
)
}
func MarketingDeliverySingleAttributionQuery(db *gorm.DB) *gorm.DB {
return db.
Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
Select(`
mda.marketing_delivery_product_id,
CASE
WHEN COUNT(DISTINCT mda.project_flock_kandang_id) = 1 THEN MIN(mda.project_flock_kandang_id)
ELSE NULL
END AS attributed_project_flock_kandang_id
`).
Group("mda.marketing_delivery_product_id")
}
func MarketingDeliveryAttributionFilterSQL(column string) string {
return fmt.Sprintf("EXISTS (SELECT 1 FROM (?) AS mda WHERE mda.marketing_delivery_product_id = %s)", column)
}
@@ -0,0 +1,147 @@
package repository
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestMarketingDeliveryAttributionRowsQueryIncludesMappedAndFallbackRows(t *testing.T) {
db := setupMarketingAttributionTestDB(t)
statements := []string{
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
var rows []MarketingDeliveryAttributionRow
if err := db.Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC").
Scan(&rows).Error; err != nil {
t.Fatalf("failed scanning attribution rows: %v", err)
}
if len(rows) != 4 {
t.Fatalf("expected 4 attribution rows, got %d", len(rows))
}
if rows[0].MarketingDeliveryProductID != 601 || rows[0].ProjectFlockKandangID != 101 || rows[0].AllocatedQty != 60 {
t.Fatalf("unexpected first attribution row: %+v", rows[0])
}
if rows[1].MarketingDeliveryProductID != 601 || rows[1].ProjectFlockKandangID != 102 || rows[1].AllocatedQty != 40 {
t.Fatalf("unexpected second attribution row: %+v", rows[1])
}
if rows[2].MarketingDeliveryProductID != 602 || rows[2].ProjectFlockKandangID != 101 || rows[2].AllocatedQty != 25 {
t.Fatalf("unexpected fallback attribution row: %+v", rows[2])
}
if rows[3].MarketingDeliveryProductID != 603 || rows[3].ProjectFlockKandangID != 101 || rows[3].AllocatedQty != 12 {
t.Fatalf("unexpected egg attribution row: %+v", rows[3])
}
}
func TestMarketingDeliverySingleAttributionQueryOnlyReturnsSingleSourceRows(t *testing.T) {
db := setupMarketingAttributionTestDB(t)
statements := []string{
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
type singleRow struct {
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
AttributedProjectFlockKandangID *uint `gorm:"column:attributed_project_flock_kandang_id"`
}
var rows []singleRow
if err := db.Table("(?) AS mda", MarketingDeliverySingleAttributionQuery(db)).
Order("mda.marketing_delivery_product_id ASC").
Scan(&rows).Error; err != nil {
t.Fatalf("failed scanning single attribution rows: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
if rows[0].MarketingDeliveryProductID != 601 || rows[0].AttributedProjectFlockKandangID != nil {
t.Fatalf("expected pooled delivery 601 to have nil single attribution, got %+v", rows[0])
}
if rows[1].MarketingDeliveryProductID != 602 || rows[1].AttributedProjectFlockKandangID == nil || *rows[1].AttributedProjectFlockKandangID != 101 {
t.Fatalf("expected fallback delivery 602 to map to kandang 101, got %+v", rows[1])
}
if rows[2].MarketingDeliveryProductID != 603 || rows[2].AttributedProjectFlockKandangID == nil || *rows[2].AttributedProjectFlockKandangID != 101 {
t.Fatalf("expected egg delivery 603 to map to kandang 101, got %+v", rows[2])
}
}
func setupMarketingAttributionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER,
stockable_type TEXT,
stockable_id INTEGER,
usable_type TEXT,
usable_id INTEGER,
qty NUMERIC(15,3),
status TEXT,
allocation_purpose TEXT
)`,
`CREATE TABLE project_flock_populations (id INTEGER PRIMARY KEY, project_chickin_id INTEGER)`,
`CREATE TABLE project_chickins (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER)`,
`CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER)`,
`CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, category TEXT)`,
`CREATE TABLE marketing_delivery_products (id INTEGER PRIMARY KEY, marketing_product_id INTEGER, usage_qty NUMERIC(15,3))`,
`CREATE TABLE marketing_products (id INTEGER PRIMARY KEY, product_warehouse_id INTEGER)`,
`CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE recording_eggs (id INTEGER PRIMARY KEY, recording_id INTEGER, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE recordings (id INTEGER PRIMARY KEY, project_flock_kandangs_id INTEGER NULL)`,
`CREATE TABLE recording_depletions (id INTEGER PRIMARY KEY, recording_id INTEGER, source_project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE purchase_items (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE stock_transfer_details (id INTEGER PRIMARY KEY, source_product_warehouse_id INTEGER NULL)`,
`CREATE TABLE laying_transfer_targets (id INTEGER PRIMARY KEY, target_project_flock_kandang_id INTEGER NULL)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -0,0 +1,18 @@
BEGIN;
DROP INDEX IF EXISTS idx_recording_depletions_source_project_flock_kandang_id;
DROP INDEX IF EXISTS idx_recording_eggs_project_flock_kandang_id;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS fk_recording_depletions_source_project_flock_kandang_id;
ALTER TABLE recording_eggs
DROP CONSTRAINT IF EXISTS fk_recording_eggs_project_flock_kandang_id;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS source_project_flock_kandang_id;
ALTER TABLE recording_eggs
DROP COLUMN IF EXISTS project_flock_kandang_id;
COMMIT;
@@ -0,0 +1,61 @@
BEGIN;
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS source_project_flock_kandang_id BIGINT NULL;
ALTER TABLE recording_eggs
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_recording_depletions_source_project_flock_kandang_id'
) THEN
ALTER TABLE recording_depletions
ADD CONSTRAINT fk_recording_depletions_source_project_flock_kandang_id
FOREIGN KEY (source_project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_recording_eggs_project_flock_kandang_id'
) THEN
ALTER TABLE recording_eggs
ADD CONSTRAINT fk_recording_eggs_project_flock_kandang_id
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_recording_depletions_source_project_flock_kandang_id
ON recording_depletions(source_project_flock_kandang_id);
CREATE INDEX IF NOT EXISTS idx_recording_eggs_project_flock_kandang_id
ON recording_eggs(project_flock_kandang_id);
UPDATE recording_depletions rd
SET source_project_flock_kandang_id = r.project_flock_kandangs_id
FROM recordings r
WHERE r.id = rd.recording_id
AND rd.source_project_flock_kandang_id IS NULL
AND r.project_flock_kandangs_id IS NOT NULL;
UPDATE recording_eggs re
SET project_flock_kandang_id = r.project_flock_kandangs_id
FROM recordings r
WHERE r.id = re.recording_id
AND re.project_flock_kandang_id IS NULL
AND r.project_flock_kandangs_id IS NOT NULL;
COMMIT;
@@ -8,6 +8,7 @@ type MarketingDeliveryProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"`
MarketingProductId uint `gorm:"uniqueIndex;not null"`
ProductWarehouseId uint `gorm:"not null"`
AttributedProjectFlockKandangId *uint `gorm:"->;column:attributed_project_flock_kandang_id"`
UnitPrice float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3)"`
AvgWeight float64 `gorm:"type:numeric(15,3)"`
@@ -21,4 +22,5 @@ type MarketingDeliveryProduct struct {
CreatedAt *time.Time `gorm:"type:timestamptz;not null"`
MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"`
AttributedProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:AttributedProjectFlockKandangId;references:Id"`
}
+2
View File
@@ -5,10 +5,12 @@ type RecordingDepletion struct {
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
SourceProjectFlockKandangId *uint `gorm:"column:source_project_flock_kandang_id"`
Qty float64 `gorm:"column:qty;not null"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"`
}
+2
View File
@@ -6,6 +6,7 @@ type RecordingEgg struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Qty int `gorm:"column:qty;not null"`
TotalQty float64 `gorm:"column:total_qty"`
TotalUsed float64 `gorm:"column:total_used"`
@@ -14,6 +15,7 @@ type RecordingEgg struct {
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
@@ -44,6 +44,7 @@ type PenjualanRealisasiResponseDTO struct {
// === Mapper Functions ===
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e)
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
@@ -51,11 +52,11 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
if projectFlockKandang != nil {
category = projectFlockKandang.ProjectFlock.Category
}
ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
ageInDay, ageInWeeks := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category)
var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
@@ -70,8 +71,8 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
var kandang *kandangDTO.KandangRelationDTO
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang)
if projectFlockKandang != nil && projectFlockKandang.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(projectFlockKandang.Kandang)
kandang = &mapped
}
@@ -102,6 +103,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e)
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
@@ -109,11 +111,11 @@ func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
if projectFlockKandang != nil {
category = projectFlockKandang.ProjectFlock.Category
}
ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
ageInDay, _ := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category)
return SalesDTO{
Age: ageInDay,
@@ -164,6 +166,13 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua
}
}
func resolveMarketingDeliveryProjectFlockKandang(e entity.MarketingDeliveryProduct) *entity.ProjectFlockKandang {
if e.AttributedProjectFlockKandang != nil {
return e.AttributedProjectFlockKandang
}
return e.MarketingProduct.ProductWarehouse.ProjectFlockKandang
}
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0, 0
@@ -25,8 +25,8 @@ type ClosingRepository interface {
SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error)
SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error)
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error)
FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakIncoming(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error)
FetchSapronakIncomingDetails(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error)
FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error)
@@ -90,6 +90,23 @@ type SapronakQueryParams struct {
EndDate *time.Time
}
func sapronakIncomingPurchaseQueryParts(params SapronakQueryParams) (string, []any) {
if len(params.ProjectFlockKandangIDs) > 0 {
return sapronakIncomingPurchasesScopedSQL(), []any{
fifo.UsableKeyRecordingStock.String(),
fifo.UsableKeyProjectChickin.String(),
fifo.StockableKeyPurchaseItems.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
params.ProjectFlockKandangIDs,
params.ProjectFlockKandangIDs,
params.WarehouseIDs,
}
}
return sapronakIncomingPurchasesSQL, []any{params.WarehouseIDs}
}
func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) {
db := r.DB().WithContext(ctx)
@@ -103,8 +120,10 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
if len(params.WarehouseIDs) == 0 {
return []SapronakRow{}, 0, nil
}
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
purchasesSQL, purchaseArgs := sapronakIncomingPurchaseQueryParts(params)
unionParts = append(unionParts, purchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, purchaseArgs...)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
@@ -193,8 +212,10 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S
if len(params.WarehouseIDs) == 0 {
return []SapronakSummaryRow{}, nil
}
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
purchasesSQL, purchaseArgs := sapronakIncomingPurchaseQueryParts(params)
unionParts = append(unionParts, purchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, purchaseArgs...)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
@@ -298,10 +319,11 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c
err = r.DB().WithContext(ctx).
Table("recording_stocks rs").
Joins("JOIN recordings rec ON rec.id = rs.recording_id").
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("rec.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
Where("f.name = ?", "PAKAN").
Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used").
Scan(&usageAgg).Error
@@ -340,10 +362,11 @@ func (r *ClosingRepositoryImpl) SumClaimCullingByProjectFlockKandangIDs(ctx cont
err := r.DB().WithContext(ctx).
Table("recording_depletions rd").
Joins("JOIN recordings rec ON rec.id = rd.recording_id").
Joins("JOIN product_warehouses pw ON pw.id = rd.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("COALESCE(rd.source_project_flock_kandang_id, rec.project_flock_kandangs_id) IN ?", projectFlockKandangIDs).
Where("f.name = ?", utils.FlagAyamCulling).
Select("COALESCE(SUM(rd.qty), 0) AS total_culling").
Scan(&agg).Error
@@ -358,52 +381,14 @@ func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs
if len(projectFlockKandangIDs) == 0 {
return 0, 0, 0, nil
}
var agg struct {
TotalWeight float64 `gorm:"column:total_weight"`
TotalQty float64 `gorm:"column:total_qty"`
TotalPrice float64 `gorm:"column:total_price"`
}
err := r.DB().WithContext(ctx).
Table("marketing_products mp").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Select("COALESCE(SUM(mp.total_weight), 0) AS total_weight, COALESCE(SUM(mp.qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price").
Scan(&agg).Error
if err != nil {
return 0, 0, 0, err
}
return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil
return r.sumMarketingAttributedByProjectFlockKandangIDs(ctx, projectFlockKandangIDs, nil)
}
func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) {
if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 {
return 0, 0, 0, nil
}
var agg struct {
TotalWeight float64 `gorm:"column:total_weight"`
TotalQty float64 `gorm:"column:total_qty"`
TotalPrice float64 `gorm:"column:total_price"`
}
err := r.DB().WithContext(ctx).
Table("marketing_products mp").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id").
Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("f.name IN ?", flagNames).
Select("COALESCE(SUM(mdp.total_weight), 0) AS total_weight, COALESCE(SUM(mdp.usage_qty), 0) AS total_qty, COALESCE(SUM(mdp.total_price), 0) AS total_price").
Scan(&agg).Error
if err != nil {
return 0, 0, 0, err
}
return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil
return r.sumMarketingAttributedByProjectFlockKandangIDs(ctx, projectFlockKandangIDs, flagNames)
}
func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) {
@@ -417,10 +402,11 @@ func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFla
err := r.DB().WithContext(ctx).
Table("recording_eggs re").
Joins("JOIN recordings rec ON rec.id = re.recording_id").
Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("COALESCE(re.project_flock_kandang_id, rec.project_flock_kandangs_id) IN ?", projectFlockKandangIDs).
Where("f.name IN ?", flagNames).
Select("COALESCE(SUM(re.qty), 0) AS total_qty").
Scan(&agg).Error
@@ -817,6 +803,52 @@ type SapronakDetailRow struct {
func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) }
func (r *ClosingRepositoryImpl) sumMarketingAttributedByProjectFlockKandangIDs(
ctx context.Context,
projectFlockKandangIDs []uint,
flagNames []string,
) (float64, float64, float64, error) {
var agg struct {
TotalWeight float64 `gorm:"column:total_weight"`
TotalQty float64 `gorm:"column:total_qty"`
TotalPrice float64 `gorm:"column:total_price"`
}
query := r.withCtx(ctx).
Table("(?) AS mda", repository.MarketingDeliveryAttributionRowsQuery(r.withCtx(ctx))).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = mda.marketing_delivery_product_id").
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id").
Where("mda.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("mdp.delivery_date IS NOT NULL")
if len(flagNames) > 0 {
query = query.
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.name IN ?", flagNames)
}
err := query.
Select(`
COALESCE(SUM(CASE
WHEN COALESCE(mdp.usage_qty, 0) > 0 THEN mdp.total_weight * (mda.allocated_qty / mdp.usage_qty)
ELSE 0
END), 0) AS total_weight,
COALESCE(SUM(mda.allocated_qty), 0) AS total_qty,
COALESCE(SUM(CASE
WHEN COALESCE(mdp.usage_qty, 0) > 0 THEN mdp.total_price * (mda.allocated_qty / mdp.usage_qty)
ELSE 0
END), 0) AS total_price
`).
Scan(&agg).Error
if err != nil {
return 0, 0, 0, err
}
return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil
}
func applyDateRange(db *gorm.DB, column string, start, end *time.Time) *gorm.DB {
if start != nil {
db = db.Where(column+"::date >= ?", start)
@@ -844,6 +876,140 @@ func sapronakFlags(flags ...utils.FlagType) []string {
return out
}
func sapronakLegacyFlagByProductCategoryCase(categoryCodeExpr string) string {
return fmt.Sprintf(
`CASE
WHEN UPPER(%s) = 'DOC' THEN '%s'
WHEN UPPER(%s) = 'PLT' THEN '%s'
WHEN UPPER(%s) IN ('RAW', 'PST', 'STR', 'FSR') THEN '%s'
WHEN UPPER(%s) IN ('OBT', 'VTM', 'KMA') THEN '%s'
ELSE NULL
END`,
categoryCodeExpr, utils.FlagDOC,
categoryCodeExpr, utils.FlagPullet,
categoryCodeExpr, utils.FlagPakan,
categoryCodeExpr, utils.FlagOVK,
)
}
func sapronakIncomingPurchasesScopedSQL() string {
return `
WITH scoped_farm_allocations AS (
SELECT
sa.stockable_id AS purchase_item_id,
COALESCE(SUM(sa.qty), 0) AS allocated_qty
FROM stock_allocations sa
LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?
LEFT JOIN recordings rec ON rec.id = rs.recording_id AND rec.deleted_at IS NULL
LEFT JOIN project_chickins pc ON pc.id = sa.usable_id AND sa.usable_type = ?
WHERE sa.stockable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) IN ?
GROUP BY sa.stockable_id
)
SELECT
CAST(pi.id AS BIGINT) AS id,
COALESCE(pi.received_date, '1970-01-01') AS sort_date,
COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(p.po_number, '') AS reference_number,
'Pembelian' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
'-' AS source_warehouse,
w.name AS destination_warehouse,
'' AS destination,
pi.total_qty AS quantity,
u.id AS unit_id,
u.name AS unit,
COALESCE(p.notes, '') AS notes
FROM purchase_items pi
JOIN purchases p ON p.id = pi.purchase_id
JOIN products prod ON prod.id = pi.product_id
JOIN uoms u ON u.id = prod.uom_id
JOIN warehouses w ON w.id = pi.warehouse_id
WHERE w.kandang_id IS NOT NULL
AND (
pi.project_flock_kandang_id IN ?
OR (pi.project_flock_kandang_id IS NULL AND pi.warehouse_id IN ?)
)
UNION ALL
SELECT
CAST(pi.id AS BIGINT) AS id,
COALESCE(pi.received_date, '1970-01-01') AS sort_date,
COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(p.po_number, '') AS reference_number,
'Pembelian' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
'-' AS source_warehouse,
w.name AS destination_warehouse,
'' AS destination,
sfa.allocated_qty AS quantity,
u.id AS unit_id,
u.name AS unit,
COALESCE(p.notes, '') AS notes
FROM purchase_items pi
JOIN purchases p ON p.id = pi.purchase_id
JOIN products prod ON prod.id = pi.product_id
JOIN uoms u ON u.id = prod.uom_id
JOIN warehouses w ON w.id = pi.warehouse_id
JOIN scoped_farm_allocations sfa ON sfa.purchase_item_id = pi.id
WHERE w.kandang_id IS NULL
AND COALESCE(sfa.allocated_qty, 0) > 0
`
}
var (
sapronakFlagsAll = sapronakFlags(utils.FlagDOC, utils.FlagPakan, utils.FlagOVK, utils.FlagPullet)
sapronakFlagsUsage = sapronakFlags(utils.FlagPakan, utils.FlagOVK)
@@ -851,18 +1017,44 @@ var (
)
func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB {
subquery := r.DB().
actualFlags := r.DB().
Table("flags").
Select("DISTINCT ON (flagable_id) flagable_id, name").
Select(`
flagable_id,
MIN(CASE
WHEN UPPER(name) = 'DOC' THEN 1
WHEN UPPER(name) = 'PULLET' THEN 2
WHEN UPPER(name) = 'PAKAN' THEN 3
WHEN UPPER(name) = 'OVK' THEN 4
ELSE 5
END) AS priority
`).
Where("flagable_type = ?", entity.FlagableTypeProduct).
Where("name IN ?", sapronakFlagsAll).
Order(fmt.Sprintf(
"flagable_id, CASE WHEN name = '%s' THEN 1 WHEN name = '%s' THEN 2 WHEN name = '%s' THEN 3 WHEN name = '%s' THEN 4 ELSE 5 END",
Where("UPPER(name) IN ?", sapronakFlagsAll).
Group("flagable_id")
legacyFlagExpr := sapronakLegacyFlagByProductCategoryCase("pc.code")
subquery := r.DB().
Table("products AS sapronak_products").
Select(fmt.Sprintf(`
sapronak_products.id AS flagable_id,
CASE
WHEN actual_flags.priority = 1 THEN '%s'
WHEN actual_flags.priority = 2 THEN '%s'
WHEN actual_flags.priority = 3 THEN '%s'
WHEN actual_flags.priority = 4 THEN '%s'
ELSE %s
END AS name
`,
utils.FlagDOC,
utils.FlagPullet,
utils.FlagPakan,
utils.FlagOVK,
))
legacyFlagExpr,
)).
Joins("LEFT JOIN (?) AS actual_flags ON actual_flags.flagable_id = sapronak_products.id", actualFlags).
Joins("LEFT JOIN product_categories pc ON pc.id = sapronak_products.product_category_id").
Where("actual_flags.priority IS NOT NULL OR " + legacyFlagExpr + " IS NOT NULL")
return db.Joins("JOIN (?) f ON f.flagable_id = "+productAlias+".id", subquery)
}
@@ -1121,22 +1313,111 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint, start, end *time.Time) *gorm.DB {
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) *gorm.DB {
db := r.withCtx(ctx).
Table("purchase_items AS pi").
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
Joins("JOIN products p ON p.id = pi.product_id").
Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL")
if projectFlockKandangID > 0 {
db = db.Where(
"w.kandang_id = ? AND (pi.project_flock_kandang_id = ? OR pi.project_flock_kandang_id IS NULL)",
kandangID,
projectFlockKandangID,
)
} else {
db = db.Where("w.kandang_id = ?", kandangID)
}
db = applyDateRange(db, "pi.received_date", start, end)
return r.joinSapronakProductFlag(db, "p")
}
func (r *ClosingRepositoryImpl) incomingFarmPurchaseAllocationBase(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) *gorm.DB {
db := r.withCtx(ctx).
Table("stock_allocations AS sa").
Joins("JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
Joins("JOIN products p ON p.id = pi.product_id").
Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()).
Joins("LEFT JOIN recordings rec ON rec.id = rs.recording_id AND rec.deleted_at IS NULL").
Joins("LEFT JOIN project_chickins pc ON pc.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id IS NULL").
Where("COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL")
db = applyDateRange(db, "pi.received_date", start, end)
return r.joinSapronakProductFlag(db, "p")
}
func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) {
func mergeSapronakIncomingRows(primary []SapronakIncomingRow, extra []SapronakIncomingRow) []SapronakIncomingRow {
if len(extra) == 0 {
return primary
}
type key struct {
productID uint
flag string
}
merged := make(map[key]*SapronakIncomingRow, len(primary)+len(extra))
order := make([]key, 0, len(primary)+len(extra))
add := func(rows []SapronakIncomingRow) {
for _, row := range rows {
k := key{productID: row.ProductID, flag: row.Flag}
if existing, ok := merged[k]; ok {
existing.Qty += row.Qty
existing.Value += row.Value
if existing.ProductName == "" {
existing.ProductName = row.ProductName
}
if existing.DefaultPrice == 0 {
existing.DefaultPrice = row.DefaultPrice
}
continue
}
copyRow := row
merged[k] = &copyRow
order = append(order, k)
}
}
add(primary)
add(extra)
result := make([]SapronakIncomingRow, 0, len(order))
for _, k := range order {
result = append(result, *merged[k])
}
return result
}
func mergeSapronakDetailMaps(primary map[uint][]SapronakDetailRow, extra map[uint][]SapronakDetailRow) map[uint][]SapronakDetailRow {
if len(primary) == 0 && len(extra) == 0 {
return map[uint][]SapronakDetailRow{}
}
if len(extra) == 0 {
return primary
}
if len(primary) == 0 {
return extra
}
for productID, rows := range extra {
primary[productID] = append(primary[productID], rows...)
}
return primary
}
func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) {
rows := make([]SapronakIncomingRow, 0)
db := r.incomingPurchaseBase(ctx, kandangID, start, end).Select(`
db := r.incomingPurchaseBase(ctx, projectFlockKandangID, kandangID, start, end).Select(`
pi.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
@@ -1147,12 +1428,30 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kanda
if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil {
return nil, err
}
if projectFlockKandangID == 0 {
return rows, nil
}
func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return scanAndGroupDetails(
r.incomingPurchaseBase(ctx, kandangID, start, end).Select(`
farmRows := make([]SapronakIncomingRow, 0)
farmDB := r.incomingFarmPurchaseAllocationBase(ctx, projectFlockKandangID, start, end).Select(`
pi.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(SUM(sa.qty), 0) AS qty,
COALESCE(SUM(sa.qty * pi.price), 0) AS value,
COALESCE(p.product_price, 0) AS default_price
`)
if err := farmDB.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&farmRows).Error; err != nil {
return nil, err
}
return mergeSapronakIncomingRows(rows, farmRows), nil
}
func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
rows, err := scanAndGroupDetails(
r.incomingPurchaseBase(ctx, projectFlockKandangID, kandangID, start, end).Select(`
pi.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
@@ -1163,6 +1462,34 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context
COALESCE(pi.price,0) AS price
`),
)
if err != nil {
return nil, err
}
if projectFlockKandangID == 0 {
return rows, nil
}
farmRows, err := scanAndGroupDetails(
r.incomingFarmPurchaseAllocationBase(ctx, projectFlockKandangID, start, end).Select(`
pi.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
pi.received_date AS date,
COALESCE(po.po_number, '') AS reference,
COALESCE(SUM(sa.qty),0) AS qty_in,
0 AS qty_out,
COALESCE(pi.price,0) AS price
`).Group(`
pi.id, pi.product_id, p.name, f.name,
pi.received_date, po.po_number, pi.price
`),
)
if err != nil {
return nil, err
}
return mergeSapronakDetailMaps(rows, farmRows), nil
}
type stockLogSapronakRow struct {
@@ -1453,6 +1780,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
}
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
attributedProjectFlockKandangExpr := `
COALESCE(
pc.project_flock_kandang_id,
pi.project_flock_kandang_id,
source_pw.project_flock_kandang_id,
ltt.target_project_flock_kandang_id,
pw.project_flock_kandang_id
)
`
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
@@ -1470,9 +1807,15 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where(attributedProjectFlockKandangExpr+" = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
@@ -1548,6 +1891,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C
END
`, pfpType)
attributedProjectFlockKandangExpr := `
COALESCE(
pc.project_flock_kandang_id,
pi.project_flock_kandang_id,
source_pw.project_flock_kandang_id,
ltt.target_project_flock_kandang_id,
pw_sales.project_flock_kandang_id
)
`
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(fmt.Sprintf(`
@@ -1600,6 +1953,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id").
@@ -1619,7 +1973,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where(attributedProjectFlockKandangExpr+" = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group(`
p_resolve.id, p_resolve.name, f.name,
@@ -0,0 +1,208 @@
package repository
import (
"context"
"fmt"
"testing"
"time"
"github.com/glebarez/sqlite"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlockKandangIDsProvided(t *testing.T) {
sql, args := sapronakIncomingPurchaseQueryParts(SapronakQueryParams{
WarehouseIDs: []uint{46},
ProjectFlockKandangIDs: []uint{101},
})
if sql != sapronakIncomingPurchasesScopedSQL() {
t.Fatalf("expected scoped purchase SQL, got %q", sql)
}
if len(args) != 8 {
t.Fatalf("expected 8 argument groups, got %d", len(args))
}
}
func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWarehouseFallback(t *testing.T) {
db := setupClosingRepositoryTestDB(t)
repo := NewClosingRepository(db)
ctx := context.Background()
receivedAt := time.Date(2026, 4, 1, 4, 0, 0, 0, time.UTC)
statements := []string{
`INSERT INTO warehouses (id, kandang_id) VALUES (1, NULL), (2, 59), (3, 88)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'OBT'), (2, 'RAW')`,
`INSERT INTO products (id, name, product_category_id, product_price) VALUES
(10, 'MEFISTO @1 LITER', 1, 261700),
(20, 'PAKAN GROWING CRUMBLE MALINDO', 2, 15000)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES
(1, 10, 'products', 'OVK'),
(2, 10, 'products', 'OBAT')`,
`INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`,
`INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`,
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty) VALUES (21, 11, 501, 150), (22, 12, 502, 10)`,
`INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES
(1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
(2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'),
(3, 1, 20, 2, NULL, 25, 12000, '` + receivedAt.Format(time.RFC3339) + `'),
(4, 1, 10, 3, 999, 10, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
(5, 1, 20, 1, NULL, 40, 15000, '` + receivedAt.Format(time.RFC3339) + `')`,
fmt.Sprintf(`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status) VALUES
(1, 701, '%s', 1, '%s', 21, 100, 'CONSUME', 'ACTIVE'),
(2, 702, '%s', 2, '%s', 21, 50, 'CONSUME', 'ACTIVE'),
(3, 703, '%s', 5, '%s', 22, 40, 'CONSUME', 'ACTIVE')`,
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyRecordingStock.String(),
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyRecordingStock.String(),
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyRecordingStock.String(),
),
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding schema: %v", err)
}
}
rows, err := repo.FetchSapronakIncoming(ctx, 101, 59, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 sapronak rows, got %d", len(rows))
}
byProduct := make(map[uint]SapronakIncomingRow, len(rows))
for _, row := range rows {
byProduct[row.ProductID] = row
}
if got := byProduct[10]; got.ProductID == 0 || got.Flag != "OVK" || got.Qty != 100 {
t.Fatalf("expected OVK farm purchase qty 100 for product 10, got %+v", got)
}
if got := byProduct[20]; got.ProductID == 0 || got.Flag != "PAKAN" || got.Qty != 75 {
t.Fatalf("expected PAKAN total qty 75 including farm allocated qty 50 and kandang receipt qty 25, got %+v", got)
}
}
func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY,
kandang_id INTEGER NULL
)`,
`CREATE TABLE product_categories (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL
)`,
`CREATE TABLE uoms (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)`,
`CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
product_category_id INTEGER NULL,
uom_id INTEGER NULL,
product_price NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
`CREATE TABLE flags (
id INTEGER PRIMARY KEY,
flagable_id INTEGER NOT NULL,
flagable_type TEXT NOT NULL,
name TEXT NOT NULL
)`,
`CREATE TABLE purchases (
id INTEGER PRIMARY KEY,
po_number TEXT NULL,
notes TEXT NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE purchase_items (
id INTEGER PRIMARY KEY,
purchase_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
received_date TIMESTAMP NULL
)`,
`CREATE TABLE recordings (
id INTEGER PRIMARY KEY,
project_flock_kandangs_id INTEGER NOT NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE recording_stocks (
id INTEGER PRIMARY KEY,
recording_id INTEGER NOT NULL,
product_warehouse_id INTEGER NOT NULL,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NOT NULL,
stockable_type TEXT NOT NULL,
stockable_id INTEGER NOT NULL,
usable_type TEXT NOT NULL,
usable_id INTEGER NOT NULL,
qty NUMERIC(15,3) NOT NULL DEFAULT 0,
allocation_purpose TEXT NOT NULL,
status TEXT NOT NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE stock_transfers (
id INTEGER PRIMARY KEY,
from_warehouse_id INTEGER NULL,
to_warehouse_id INTEGER NULL,
transfer_date TIMESTAMP NULL,
movement_number TEXT NULL,
reason TEXT NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
dest_product_warehouse_id INTEGER NULL,
source_product_warehouse_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
`CREATE TABLE adjustment_stocks (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NOT NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
adj_number TEXT NULL,
created_at TIMESTAMP NULL
)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -383,7 +383,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
var projectFlockKandangIDs []uint
if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
} else {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
@@ -474,7 +474,7 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
var projectFlockKandangIDs []uint
if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
} else {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
@@ -382,11 +382,11 @@ func buildSapronakDetails(
func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) {
// Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any).
startDate, endDate := sapronakPeriodRange(pfk)
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, startDate, endDate)
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.Id, pfk.KandangId, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId, startDate, endDate)
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.Id, pfk.KandangId, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
@@ -29,6 +29,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
Limit: c.QueryInt("limit", 10),
ProductId: uint(c.QueryInt("product_id", 0)),
WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
LocationId: uint(c.QueryInt("location_id", 0)),
Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)),
TransferContext: c.Query(utils.TransferContextKey, ""),
@@ -0,0 +1,64 @@
package controller
import (
"errors"
"net/http/httptest"
"testing"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations"
"gorm.io/gorm"
)
type stubProductWarehouseService struct {
lastQuery *validation.Query
}
func (s *stubProductWarehouseService) GetAll(_ *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
s.lastQuery = params
return []entity.ProductWarehouse{}, 0, nil
}
func (s *stubProductWarehouseService) GetOne(_ *fiber.Ctx, _ uint) (*entity.ProductWarehouse, error) {
return nil, gorm.ErrRecordNotFound
}
var _ service.ProductWarehouseService = (*stubProductWarehouseService)(nil)
func TestGetAllParsesLocationID(t *testing.T) {
app := fiber.New()
stub := &stubProductWarehouseService{}
ctrl := NewProductWarehouseController(stub)
app.Get("/product-warehouses", ctrl.GetAll)
req := httptest.NewRequest("GET", "/product-warehouses?location_id=16&kandang_id=59&limit=25", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
if stub.lastQuery == nil {
t.Fatalf("expected service to receive query")
}
if stub.lastQuery.LocationId != 16 {
t.Fatalf("expected location_id 16, got %d", stub.lastQuery.LocationId)
}
if stub.lastQuery.KandangId != 59 {
t.Fatalf("expected kandang_id 59, got %d", stub.lastQuery.KandangId)
}
if stub.lastQuery.Limit != 25 {
t.Fatalf("expected limit 25, got %d", stub.lastQuery.Limit)
}
}
func TestStubImplementsServiceContract(t *testing.T) {
validate := validator.New()
if validate == nil {
t.Fatal(errors.New("validator should not be nil"))
}
}
@@ -7,6 +7,7 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
@@ -84,31 +85,28 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho
}
func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId).
Order("id DESC").
Preload("ProjectFlockKandang").
First(&productWarehouse).Error
if err == nil {
if productWarehouse.ProjectFlockKandang.ClosedAt == nil {
return &productWarehouse, nil
}
}
err = r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId).
First(&productWarehouse).Error
warehouseIsKandang, err := r.isKandangWarehouse(ctx, warehouseId)
if err != nil {
return nil, err
}
return &productWarehouse, nil
if warehouseIsKandang {
if productWarehouse, err := r.findOpenKandangOwnedWarehouse(ctx, productId, warehouseId); err == nil {
return productWarehouse, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return r.findSharedWarehouse(ctx, productId, warehouseId)
}
if productWarehouse, err := r.findSharedWarehouse(ctx, productId, warehouseId); err == nil {
return productWarehouse, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return r.findOpenKandangOwnedWarehouse(ctx, productId, warehouseId)
}
func (r *ProductWarehouseRepositoryImpl) FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) {
@@ -167,10 +165,42 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s
return db
}
return db.
fallbackCategoryCodes := utils.LegacyProductCategoryCodesForFlags(flags)
db = db.
Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id").
Joins("JOIN flags f_flag ON f_flag.flagable_id = p_flag.id AND f_flag.flagable_type = ?", "products").
Where("f_flag.name IN ?", flags).
Joins("LEFT JOIN product_categories pc_flag ON pc_flag.id = p_flag.product_category_id")
actualFlagFilter := `
EXISTS (
SELECT 1
FROM flags f_flag
WHERE f_flag.flagable_id = p_flag.id
AND f_flag.flagable_type = ?
AND f_flag.name IN ?
)
`
if len(fallbackCategoryCodes) == 0 {
return db.Where(actualFlagFilter, entity.FlagableTypeProduct, flags).Distinct()
}
return db.
Where(
`(`+actualFlagFilter+`) OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_id = p_flag.id
AND f_any.flagable_type = ?
)
AND pc_flag.code IN ?
)`,
entity.FlagableTypeProduct,
flags,
entity.FlagableTypeProduct,
fallbackCategoryCodes,
).
Distinct()
}
@@ -266,18 +296,8 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse(
projectFlockKandangID *uint,
createdBy uint,
) (uint, error) {
record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID)
record, err := r.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID)
if err == nil {
// Backfill project_flock_kandang_id when it's missing and caller provides one.
if projectFlockKandangID != nil && (record.ProjectFlockKandangId == nil || *record.ProjectFlockKandangId == 0) {
if err := r.DB().WithContext(ctx).
Model(&entity.ProductWarehouse{}).
Where("id = ?", record.Id).
Update("project_flock_kandang_id", *projectFlockKandangID).Error; err != nil {
return 0, err
}
record.ProjectFlockKandangId = projectFlockKandangID
}
return record.Id, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -301,6 +321,45 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse(
return entity.Id, nil
}
func (r *ProductWarehouseRepositoryImpl) isKandangWarehouse(ctx context.Context, warehouseID uint) (bool, error) {
var kandangID *uint
if err := r.DB().WithContext(ctx).
Table("warehouses").
Select("kandang_id").
Where("id = ?", warehouseID).
Scan(&kandangID).Error; err != nil {
return false, err
}
return kandangID != nil && *kandangID != 0, nil
}
func (r *ProductWarehouseRepositoryImpl) findOpenKandangOwnedWarehouse(ctx context.Context, productID uint, warehouseID uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productID, warehouseID).
Order("id DESC").
Preload("ProjectFlockKandang").
First(&productWarehouse).Error
if err != nil {
return nil, err
}
if productWarehouse.ProjectFlockKandang != nil && productWarehouse.ProjectFlockKandang.ClosedAt == nil {
return &productWarehouse, nil
}
return nil, gorm.ErrRecordNotFound
}
func (r *ProductWarehouseRepositoryImpl) findSharedWarehouse(ctx context.Context, productID uint, warehouseID uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
if err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productID, warehouseID).
Preload("ProjectFlockKandang").
First(&productWarehouse).Error; err != nil {
return nil, err
}
return &productWarehouse, nil
}
func (r *ProductWarehouseRepositoryImpl) GetByProductWarehouseAndProjectFlockKandang(
ctx context.Context,
productId uint,
@@ -0,0 +1,190 @@
package repository
import (
"context"
"testing"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
func TestGetProductWarehouseByProductAndWarehouseIDPrefersSharedForFarmWarehouse(t *testing.T) {
db := setupProductWarehouseRepoTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
insertProductWarehouseTestFixtures(t, db)
got, err := repo.GetProductWarehouseByProductAndWarehouseID(ctx, 1, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Id != 2 {
t.Fatalf("expected shared farm warehouse id 2, got %d", got.Id)
}
}
func TestGetProductWarehouseByProductAndWarehouseIDPrefersOpenKandangOwnedForKandangWarehouse(t *testing.T) {
db := setupProductWarehouseRepoTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
insertProductWarehouseTestFixtures(t, db)
got, err := repo.GetProductWarehouseByProductAndWarehouseID(ctx, 1, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Id != 3 {
t.Fatalf("expected kandang-owned warehouse id 3, got %d", got.Id)
}
}
func TestEnsureProductWarehouseDoesNotBackfillSharedWarehouse(t *testing.T) {
db := setupProductWarehouseRepoTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
insertProductWarehouseTestFixtures(t, db)
projectFlockKandangID := uint(101)
createdID, err := repo.EnsureProductWarehouse(ctx, 1, 1, &projectFlockKandangID, 9)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if createdID == 2 {
t.Fatalf("expected new kandang-attributed row instead of reusing shared row")
}
var sharedPfkID *uint
if err := db.WithContext(ctx).
Table("product_warehouses").
Select("project_flock_kandang_id").
Where("id = ?", 2).
Scan(&sharedPfkID).Error; err != nil {
t.Fatalf("failed to load shared warehouse row: %v", err)
}
if sharedPfkID != nil {
t.Fatalf("expected shared row attribution to stay nil, got %v", *sharedPfkID)
}
}
func setupProductWarehouseRepoTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (id INTEGER PRIMARY KEY, kandang_id INTEGER NULL)`,
`CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, closed_at TIMESTAMP NULL)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL,
qty NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
func insertProductWarehouseTestFixtures(t *testing.T, db *gorm.DB) {
t.Helper()
statements := []string{
`INSERT INTO warehouses (id, kandang_id) VALUES (1, NULL), (2, 7)`,
`INSERT INTO project_flock_kandangs (id, closed_at) VALUES (101, NULL)`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES
(1, 1, 1, 101, 10),
(2, 1, 1, NULL, 20),
(3, 1, 2, 101, 30),
(4, 1, 2, NULL, 40)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
}
func TestApplyFlagsFilterIncludesLegacyCategoryFallback(t *testing.T) {
db := setupProductWarehouseFlagFilterTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
var ids []uint
err := repo.ApplyFlagsFilter(
db.WithContext(ctx).Model(&entity.ProductWarehouse{}),
[]string{"PAKAN"},
).Order("product_warehouses.id").Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("expected flagged and legacy RAW rows to match, got %v", ids)
}
}
func TestApplyFlagsFilterDoesNotFallbackWhenProductAlreadyHasDifferentFlags(t *testing.T) {
db := setupProductWarehouseFlagFilterTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
var ids []uint
err := repo.ApplyFlagsFilter(
db.WithContext(ctx).Model(&entity.ProductWarehouse{}),
[]string{"PAKAN"},
).Where("product_warehouses.id = ?", 3).Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ids) != 0 {
t.Fatalf("expected OVK-flagged product not to match PAKAN fallback, got %v", ids)
}
}
func setupProductWarehouseFlagFilterTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE product_categories (id INTEGER PRIMARY KEY, code TEXT NOT NULL)`,
`CREATE TABLE products (id INTEGER PRIMARY KEY, product_category_id INTEGER NOT NULL)`,
`CREATE TABLE flags (id INTEGER PRIMARY KEY, flagable_id INTEGER NOT NULL, flagable_type TEXT NOT NULL, name TEXT NOT NULL)`,
`CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, warehouse_id INTEGER NOT NULL, project_flock_kandang_id INTEGER NULL, qty NUMERIC(15,3) NOT NULL DEFAULT 0)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'STR'), (2, 'RAW'), (3, 'OBT')`,
`INSERT INTO products (id, product_category_id) VALUES (10, 1), (20, 2), (30, 2), (40, 3)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES
(1, 10, 'products', 'PAKAN'),
(2, 10, 'products', 'STARTER'),
(3, 40, 'products', 'OVK'),
(4, 40, 'products', 'OBAT')`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES
(1, 10, 1, NULL, 10),
(2, 20, 1, NULL, 20),
(3, 40, 1, NULL, 30)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -53,6 +53,24 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Chickins")
}
func applyWarehouseSelectionFilter(db *gorm.DB, kandangID, locationID uint) *gorm.DB {
switch {
case kandangID != 0 && locationID != 0:
return db.Where(
"w_scope.location_id = ? AND (w_scope.type = ? OR w_scope.kandang_id = ?)",
locationID,
"LOKASI",
kandangID,
)
case kandangID != 0:
return db.Where("w_scope.kandang_id = ?", kandangID)
case locationID != 0:
return db.Where("w_scope.location_id = ?", locationID)
default:
return db
}
}
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -133,10 +151,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("product_id = ?", params.ProductId)
}
if params.KandangId != 0 {
db = db.Joins("JOIN warehouses ON product_warehouses.warehouse_id = warehouses.id").
Where("warehouses.kandang_id = ?", params.KandangId)
}
db = applyWarehouseSelectionFilter(db, params.KandangId, params.LocationId)
if params.WarehouseId != 0 {
db = db.Where("warehouse_id = ?", params.WarehouseId)
@@ -0,0 +1,111 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestApplyWarehouseSelectionFilterIncludesFarmAndSelectedKandangInLocation(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 101).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1, 2})
}
func TestApplyWarehouseSelectionFilterPreservesKandangOnlyBehavior(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 0).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1})
}
func TestApplyWarehouseSelectionFilterSupportsLocationOnlyQuery(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 0, 101).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1, 2, 3})
}
func setupProductWarehouseServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
location_id INTEGER NULL,
kandang_id INTEGER NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
warehouse_id INTEGER NOT NULL
)`,
`INSERT INTO warehouses (id, type, location_id, kandang_id, deleted_at) VALUES
(1, 'KANDANG', 101, 11, NULL),
(2, 'LOKASI', 101, NULL, NULL),
(3, 'KANDANG', 101, 12, NULL),
(4, 'LOKASI', 102, NULL, NULL)`,
`INSERT INTO product_warehouses (id, warehouse_id) VALUES
(1, 1),
(2, 2),
(3, 3),
(4, 4)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
func baseProductWarehouseSelectionQuery(db *gorm.DB) *gorm.DB {
return db.Table("product_warehouses").
Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id").
Where("w_scope.deleted_at IS NULL")
}
func assertUintIDs(t *testing.T, got []uint, want []uint) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("expected ids %v, got %v", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("expected ids %v, got %v", want, got)
}
}
}
@@ -17,6 +17,7 @@ type Query struct {
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
LocationId uint `query:"location_id" validate:"omitempty,number,min=1"`
Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
@@ -2,9 +2,10 @@ package repository
import (
"context"
"sort"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -12,7 +13,7 @@ import (
)
type MarketingDeliveryProductRepository interface {
repository.BaseRepository[entity.MarketingDeliveryProduct]
commonRepo.BaseRepository[entity.MarketingDeliveryProduct]
GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error)
GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error)
@@ -23,26 +24,27 @@ type MarketingDeliveryProductRepository interface {
GetUsageQty(ctx context.Context, id uint) (float64, error)
ResetFifoFields(ctx context.Context, id uint) error
GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error)
}
type MarketingDeliveryProductRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct]
*commonRepo.BaseRepositoryImpl[entity.MarketingDeliveryProduct]
}
func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository {
return &MarketingDeliveryProductRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db),
BaseRepositoryImpl: commonRepo.NewBaseRepository[entity.MarketingDeliveryProduct](db),
}
}
func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = marketing_delivery_products.id", attributionQuery).
Where("mda.project_flock_id = ?", projectFlockID).
Distinct("marketing_delivery_products.*")
if callback != nil {
@@ -57,139 +59,50 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
Distinct("marketing_delivery_products.*")
if projectFlockKandangID != nil {
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Uom").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.Warehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Preload("MarketingProduct.Marketing").
Preload("MarketingProduct.Marketing.Customer").
Order("marketing_delivery_products.delivery_date DESC")
if err := db.Find(&deliveryProducts).Error; err != nil {
attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, nil)
if err != nil {
return nil, err
}
return deliveryProducts, nil
return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID)
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("flags.name IN (?)", []string{
attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, []string{
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagPullet),
string(utils.FlagLayer),
}).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
Distinct("marketing_delivery_products.*")
if projectFlockKandangID != nil {
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Order("marketing_delivery_products.delivery_date DESC")
if err := db.Find(&deliveryProducts).Error; err != nil {
})
if err != nil {
return nil, err
}
return deliveryProducts, nil
return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID)
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
Distinct("marketing_delivery_products.*")
if projectFlockKandangID != nil {
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
}
if category == string(utils.ProjectFlockCategoryLaying) {
db = db.Where("flags.name IN (?)", []string{
string(utils.FlagTelur),
string(utils.FlagTelurUtuh),
string(utils.FlagTelurPecah),
string(utils.FlagTelurPutih),
string(utils.FlagTelurRetak),
})
} else {
db = db.Where("flags.name IN (?)", []string{
flagNames := []string{
string(utils.FlagDOC),
string(utils.FlagPullet),
string(utils.FlagLayer),
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagAyamMati),
})
}
if category == string(utils.ProjectFlockCategoryLaying) {
flagNames = []string{
string(utils.FlagTelur),
string(utils.FlagTelurUtuh),
string(utils.FlagTelurPecah),
string(utils.FlagTelurPutih),
string(utils.FlagTelurRetak),
}
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Uom").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.Warehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Preload("MarketingProduct.Marketing").
Preload("MarketingProduct.Marketing.Customer").
Order("marketing_delivery_products.delivery_date DESC")
if err := db.Find(&deliveryProducts).Error; err != nil {
attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, flagNames)
if err != nil {
return nil, err
}
return deliveryProducts, nil
return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID)
}
func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) {
@@ -219,12 +132,199 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con
return &deliveryProduct, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error) {
if len(deliveryProductIDs) == 0 {
return []commonRepo.MarketingDeliveryAttributionRow{}, nil
}
var rows []commonRepo.MarketingDeliveryAttributionRow
query := r.DB().WithContext(ctx).
Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))).
Where("mda.marketing_delivery_product_id IN ?", deliveryProductIDs).
Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC")
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) getClosingAttributionRows(
ctx context.Context,
projectFlockID uint,
projectFlockKandangID *uint,
flagNames []string,
) ([]commonRepo.MarketingDeliveryAttributionRow, error) {
var rows []commonRepo.MarketingDeliveryAttributionRow
attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))
query := r.DB().WithContext(ctx).
Table("(?) AS mda", attributionQuery).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = mda.marketing_delivery_product_id").
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id").
Where("mda.project_flock_id = ?", projectFlockID).
Where("mdp.delivery_date IS NOT NULL")
if projectFlockKandangID != nil {
query = query.Where("mda.project_flock_kandang_id = ?", *projectFlockKandangID)
}
if len(flagNames) > 0 {
query = query.
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.name IN ?", flagNames)
}
query = query.
Select(`
mda.marketing_delivery_product_id,
mda.project_flock_kandang_id,
mda.project_flock_id,
mda.project_flock_category,
SUM(mda.allocated_qty) AS allocated_qty
`).
Group(`
mda.marketing_delivery_product_id,
mda.project_flock_kandang_id,
mda.project_flock_id,
mda.project_flock_category
`).
Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC")
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) fetchClosingDeliveryProducts(
ctx context.Context,
attributionRows []commonRepo.MarketingDeliveryAttributionRow,
projectFlockKandangID *uint,
) ([]entity.MarketingDeliveryProduct, error) {
deliveryIDs := orderedDeliveryProductIDs(attributionRows)
if len(deliveryIDs) == 0 {
return []entity.MarketingDeliveryProduct{}, nil
}
query := r.closingDeliveryProductsQuery(ctx).
Where("marketing_delivery_products.id IN ?", deliveryIDs).
Order("marketing_delivery_products.delivery_date DESC")
if projectFlockKandangID == nil {
query = query.Joins(
"LEFT JOIN (?) AS mda_single ON mda_single.marketing_delivery_product_id = marketing_delivery_products.id",
commonRepo.MarketingDeliverySingleAttributionQuery(r.DB().WithContext(ctx)),
).Select("marketing_delivery_products.*, mda_single.attributed_project_flock_kandang_id")
}
var deliveryProducts []entity.MarketingDeliveryProduct
if err := query.Find(&deliveryProducts).Error; err != nil {
return nil, err
}
if projectFlockKandangID == nil {
return deliveryProducts, nil
}
return scaleDeliveryProductsByAttribution(deliveryProducts, attributionRows, *projectFlockKandangID), nil
}
func (r *MarketingDeliveryProductRepositoryImpl) closingDeliveryProductsQuery(ctx context.Context) *gorm.DB {
return r.DB().WithContext(ctx).
Model(&entity.MarketingDeliveryProduct{}).
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Uom").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.Warehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Preload("MarketingProduct.Marketing").
Preload("MarketingProduct.Marketing.Customer").
Preload("AttributedProjectFlockKandang").
Preload("AttributedProjectFlockKandang.ProjectFlock").
Preload("AttributedProjectFlockKandang.Kandang").
Preload("AttributedProjectFlockKandang.Chickins")
}
func orderedDeliveryProductIDs(rows []commonRepo.MarketingDeliveryAttributionRow) []uint {
seen := make(map[uint]struct{}, len(rows))
ids := make([]uint, 0, len(rows))
for _, row := range rows {
if row.MarketingDeliveryProductID == 0 {
continue
}
if _, ok := seen[row.MarketingDeliveryProductID]; ok {
continue
}
seen[row.MarketingDeliveryProductID] = struct{}{}
ids = append(ids, row.MarketingDeliveryProductID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func scaleDeliveryProductsByAttribution(
deliveryProducts []entity.MarketingDeliveryProduct,
rows []commonRepo.MarketingDeliveryAttributionRow,
projectFlockKandangID uint,
) []entity.MarketingDeliveryProduct {
if len(deliveryProducts) == 0 || projectFlockKandangID == 0 {
return deliveryProducts
}
totalByDelivery := make(map[uint]float64, len(rows))
selectedByDelivery := make(map[uint]float64, len(rows))
for _, row := range rows {
totalByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty
if row.ProjectFlockKandangID == projectFlockKandangID {
selectedByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty
}
}
filtered := make([]entity.MarketingDeliveryProduct, 0, len(deliveryProducts))
for _, delivery := range deliveryProducts {
selectedQty := selectedByDelivery[delivery.Id]
totalQty := totalByDelivery[delivery.Id]
if selectedQty <= 0 {
continue
}
share := 1.0
if totalQty > 0 {
share = selectedQty / totalQty
}
cloned := delivery
cloned.AttributedProjectFlockKandangId = &projectFlockKandangID
cloned.UsageQty = selectedQty
cloned.PendingQty = 0
cloned.TotalWeight = delivery.TotalWeight * share
cloned.TotalPrice = delivery.TotalPrice * share
filtered = append(filtered, cloned)
}
return filtered
}
func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
var total int64
baseDB := r.DB().WithContext(ctx)
singleAttributionQuery := commonRepo.MarketingDeliverySingleAttributionQuery(baseDB)
db := r.DB().WithContext(ctx).
Model(&entity.MarketingDeliveryProduct{}).
Select("marketing_delivery_products.*, mda_single.attributed_project_flock_kandang_id").
Joins("LEFT JOIN (?) AS mda_single ON mda_single.marketing_delivery_product_id = marketing_delivery_products.id", singleAttributionQuery).
Preload("MarketingProduct", func(db *gorm.DB) *gorm.DB {
return db.
Preload("Marketing").
@@ -237,6 +337,9 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock")
}).
Preload("AttributedProjectFlockKandang").
Preload("AttributedProjectFlockKandang.ProjectFlock").
Preload("AttributedProjectFlockKandang.Kandang").
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id").
Where("marketing_delivery_products.delivery_date IS NOT NULL")
@@ -292,22 +395,27 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
}
if filters.AreaId > 0 || filters.LocationId > 0 || filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil {
db = db.Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id")
buildAttrFilter := func() *gorm.DB {
return r.DB().WithContext(ctx).
Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))).
Select("1").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = mda.project_flock_kandang_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("mda.marketing_delivery_product_id = marketing_delivery_products.id")
}
if filters.AreaId > 0 {
db = db.Where("project_flocks.area_id = ?", filters.AreaId)
db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.area_id = ?", filters.AreaId))
}
if filters.LocationId > 0 {
db = db.Where("project_flocks.location_id = ?", filters.LocationId)
db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.location_id = ?", filters.LocationId))
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("project_flocks.area_id IN ?", filters.AllowedAreaIDs)
db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.area_id IN ?", filters.AllowedAreaIDs))
}
}
@@ -315,7 +423,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("project_flocks.location_id IN ?", filters.AllowedLocationIDs)
db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.location_id IN ?", filters.AllowedLocationIDs))
}
}
}
@@ -0,0 +1,42 @@
package repository
import (
"testing"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func TestScaleDeliveryProductsByAttribution(t *testing.T) {
projectFlockKandangID := uint(101)
deliveryProducts := []entity.MarketingDeliveryProduct{
{
Id: 55,
UsageQty: 100,
TotalWeight: 180,
TotalPrice: 3600,
},
}
attributionRows := []commonRepo.MarketingDeliveryAttributionRow{
{MarketingDeliveryProductID: 55, ProjectFlockKandangID: 101, AllocatedQty: 60},
{MarketingDeliveryProductID: 55, ProjectFlockKandangID: 102, AllocatedQty: 40},
}
got := scaleDeliveryProductsByAttribution(deliveryProducts, attributionRows, projectFlockKandangID)
if len(got) != 1 {
t.Fatalf("expected 1 scaled delivery, got %d", len(got))
}
if got[0].UsageQty != 60 {
t.Fatalf("expected usage qty 60, got %.2f", got[0].UsageQty)
}
if got[0].TotalWeight != 108 {
t.Fatalf("expected total weight 108, got %.2f", got[0].TotalWeight)
}
if got[0].TotalPrice != 2160 {
t.Fatalf("expected total price 2160, got %.2f", got[0].TotalPrice)
}
if got[0].AttributedProjectFlockKandangId == nil || *got[0].AttributedProjectFlockKandangId != projectFlockKandangID {
t.Fatalf("expected attributed kandang id %d, got %+v", projectFlockKandangID, got[0].AttributedProjectFlockKandangId)
}
}
@@ -643,6 +643,11 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
return nil
}
affectedKandangIDs, err := s.marketingPopulationKandangIDsFromActiveAllocations(ctx, tx, deliveryProduct.Id)
if err != nil {
return err
}
deliveryProduct.UsageQty = 0
deliveryProduct.PendingQty = 0
if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
@@ -670,6 +675,9 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
if err := s.resyncPopulationUsageByKandangIDs(ctx, tx, affectedKandangIDs); err != nil {
return err
}
releasedUsage := currentUsage - deliveryProduct.UsageQty
if actorID > 0 && releasedUsage > 0 {
@@ -725,29 +733,378 @@ func (s deliveryOrdersService) allocatePopulationForMarketingDelivery(
return nil
}
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil)
exactAllocations, err := s.findDirectPopulationAllocationsForMarketing(ctx, tx, deliveryProduct.Id)
if err != nil {
return err
}
if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 {
return nil
if len(exactAllocations) > 0 {
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
if err := s.applyDirectPopulationAllocationsForMarketing(ctx, tx, productWarehouseID, deliveryProduct.Id, exactAllocations); err != nil {
return err
}
return s.resyncPopulationUsageByKandangIDs(ctx, tx, marketingAllocationKandangIDs(exactAllocations))
}
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(ctx, *pw.ProjectFlockKandangId, productWarehouseID)
sourceGroups, err := s.findPopulationSourceGroupsForMarketing(ctx, tx, deliveryProduct.Id, productWarehouseID)
if err != nil {
return err
}
if len(sourceGroups) == 0 {
return nil
}
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
for _, group := range sourceGroups {
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(
ctx,
group.ProjectFlockKandangID,
group.ProductWarehouseID,
)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery")
}
return fifoV2.AllocatePopulationConsumption(
if err := s.allocatePopulationConsumptionWithoutRelease(
ctx,
tx,
populations,
productWarehouseID,
fifo.UsableKeyMarketingDelivery.String(),
deliveryProduct.Id,
deliveryProduct.UsageQty,
)
group.Qty,
); err != nil {
return err
}
}
return s.resyncPopulationUsageByKandangIDs(ctx, tx, marketingSourceGroupKandangIDs(sourceGroups))
}
type marketingPopulationAllocation struct {
ProjectFlockPopulationID uint `gorm:"column:project_flock_population_id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
Qty float64 `gorm:"column:qty"`
}
type marketingPopulationSourceGroup struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
Qty float64 `gorm:"column:qty"`
}
func (s deliveryOrdersService) findDirectPopulationAllocationsForMarketing(
ctx context.Context,
tx *gorm.DB,
deliveryProductID uint,
) ([]marketingPopulationAllocation, error) {
var rows []marketingPopulationAllocation
err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
pfp.id AS project_flock_population_id,
pc.project_flock_kandang_id AS project_flock_kandang_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Group("pfp.id, pc.project_flock_kandang_id").
Order("pfp.id ASC").
Scan(&rows).Error
return rows, err
}
func (s deliveryOrdersService) findPopulationSourceGroupsForMarketing(
ctx context.Context,
tx *gorm.DB,
deliveryProductID uint,
productWarehouseID uint,
) ([]marketingPopulationSourceGroup, error) {
groups := make(map[string]marketingPopulationSourceGroup)
appendGroup := func(projectFlockKandangID uint, sourceProductWarehouseID uint, qty float64) {
if projectFlockKandangID == 0 || sourceProductWarehouseID == 0 || qty <= 0 {
return
}
key := fmt.Sprintf("%d:%d", projectFlockKandangID, sourceProductWarehouseID)
current := groups[key]
current.ProjectFlockKandangID = projectFlockKandangID
current.ProductWarehouseID = sourceProductWarehouseID
current.Qty += qty
groups[key] = current
}
var transferRows []marketingPopulationSourceGroup
if err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
source_pw.project_flock_kandang_id AS project_flock_kandang_id,
std.source_product_warehouse_id AS product_warehouse_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id").
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("source_pw.project_flock_kandang_id IS NOT NULL").
Group("source_pw.project_flock_kandang_id, std.source_product_warehouse_id").
Scan(&transferRows).Error; err != nil {
return nil, err
}
for _, row := range transferRows {
appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty)
}
var purchaseRows []marketingPopulationSourceGroup
if err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
pi.project_flock_kandang_id AS project_flock_kandang_id,
pi.product_warehouse_id AS product_warehouse_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("pi.project_flock_kandang_id IS NOT NULL").
Where("pi.product_warehouse_id IS NOT NULL").
Group("pi.project_flock_kandang_id, pi.product_warehouse_id").
Scan(&purchaseRows).Error; err != nil {
return nil, err
}
for _, row := range purchaseRows {
appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty)
}
var layingRows []marketingPopulationSourceGroup
if err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
ltt.product_warehouse_id AS product_warehouse_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("ltt.product_warehouse_id IS NOT NULL").
Group("ltt.target_project_flock_kandang_id, ltt.product_warehouse_id").
Scan(&layingRows).Error; err != nil {
return nil, err
}
for _, row := range layingRows {
appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty)
}
if len(groups) == 0 {
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil)
if err != nil {
return nil, err
}
if pw.ProjectFlockKandangId != nil && *pw.ProjectFlockKandangId != 0 {
appendGroup(*pw.ProjectFlockKandangId, productWarehouseID, 0)
}
}
result := make([]marketingPopulationSourceGroup, 0, len(groups))
for _, group := range groups {
if group.Qty == 0 {
group.Qty = s.resolveMarketingRequestedUsageQty(ctx, tx, deliveryProductID)
}
if group.Qty > 0 {
result = append(result, group)
}
}
return result, nil
}
func (s deliveryOrdersService) applyDirectPopulationAllocationsForMarketing(
ctx context.Context,
tx *gorm.DB,
productWarehouseID uint,
deliveryProductID uint,
allocations []marketingPopulationAllocation,
) error {
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
for _, allocation := range allocations {
if allocation.ProjectFlockPopulationID == 0 || allocation.Qty <= 0 {
continue
}
record := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: allocation.ProjectFlockPopulationID,
UsableType: fifo.UsableKeyMarketingDelivery.String(),
UsableId: deliveryProductID,
Qty: allocation.Qty,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, record, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", allocation.ProjectFlockPopulationID).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", allocation.Qty)).Error; err != nil {
return err
}
}
return nil
}
func (s deliveryOrdersService) allocatePopulationConsumptionWithoutRelease(
ctx context.Context,
tx *gorm.DB,
populations []entity.ProjectFlockPopulation,
productWarehouseID uint,
deliveryProductID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
remaining := consumeQty
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
for _, population := range populations {
available := population.TotalQty - population.TotalUsedQty
if available <= 0 {
continue
}
portion := available
if remaining < portion {
portion = remaining
}
if portion <= 0 {
continue
}
record := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: population.Id,
UsableType: fifo.UsableKeyMarketingDelivery.String(),
UsableId: deliveryProductID,
Qty: portion,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, record, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", population.Id).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil {
return err
}
remaining -= portion
if remaining <= 0.000001 {
break
}
}
if remaining > 0.000001 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi")
}
return nil
}
func (s deliveryOrdersService) marketingPopulationKandangIDsFromActiveAllocations(
ctx context.Context,
tx *gorm.DB,
deliveryProductID uint,
) ([]uint, error) {
var ids []uint
err := tx.WithContext(ctx).
Table("stock_allocations sa").
Distinct("pc.project_flock_kandang_id").
Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Pluck("pc.project_flock_kandang_id", &ids).Error
return ids, err
}
func (s deliveryOrdersService) resyncPopulationUsageByKandangIDs(ctx context.Context, tx *gorm.DB, kandangIDs []uint) error {
for _, kandangID := range uniqueUintIDs(kandangIDs) {
if kandangID == 0 {
continue
}
if err := s.ProjectFlockPopulationRepo.WithTx(tx).ResyncUsageByProjectFlockKandangID(ctx, tx, kandangID); err != nil {
return err
}
}
return nil
}
func (s deliveryOrdersService) resolveMarketingRequestedUsageQty(ctx context.Context, tx *gorm.DB, deliveryProductID uint) float64 {
var usageQty float64
if err := tx.WithContext(ctx).
Table("marketing_delivery_products").
Select("usage_qty").
Where("id = ?", deliveryProductID).
Scan(&usageQty).Error; err != nil {
return 0
}
return usageQty
}
func marketingAllocationKandangIDs(rows []marketingPopulationAllocation) []uint {
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ProjectFlockKandangID)
}
return ids
}
func marketingSourceGroupKandangIDs(rows []marketingPopulationSourceGroup) []uint {
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ProjectFlockKandangID)
}
return ids
}
func uniqueUintIDs(ids []uint) []uint {
seen := make(map[uint]struct{}, len(ids))
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
@@ -2,6 +2,7 @@ package repository
import (
"context"
"errors"
"gorm.io/gorm"
@@ -14,6 +15,7 @@ type KandangGroupRepository interface {
LocationExists(ctx context.Context, locationId uint) (bool, error)
PicExists(ctx context.Context, picId uint) (bool, error)
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
HasDailyChecklistRelation(ctx context.Context, kandangGroupId uint) (bool, error)
}
type KandangGroupRepositoryImpl struct {
@@ -39,3 +41,20 @@ func (r *KandangGroupRepositoryImpl) PicExists(ctx context.Context, picId uint)
func (r *KandangGroupRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) {
return repository.ExistsByName[entity.KandangGroup](ctx, r.db, name, excludeID)
}
func (r *KandangGroupRepositoryImpl) HasDailyChecklistRelation(ctx context.Context, kandangGroupId uint) (bool, error) {
var marker int
err := r.db.WithContext(ctx).
Model(&entity.DailyChecklist{}).
Select("1").
Where("kandang_id = ?", kandangGroupId).
Limit(1).
Take(&marker).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
@@ -226,6 +226,16 @@ func (s kandangGroupService) DeleteOne(c *fiber.Ctx, id uint) error {
if err != nil {
return err
}
hasDailyChecklistRelation, err := s.Repository.HasDailyChecklistRelation(c.Context(), id)
if err != nil {
s.Log.Errorf("Failed to check daily checklist relation for kandang group %d: %+v", id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check kandang group relation")
}
if hasDailyChecklistRelation {
return fiber.NewError(fiber.StatusConflict, "Kandang group tidak boleh dihapus karena masih memiliki relasi daily checklist")
}
if len(kandangGroup.Kandangs) > 0 {
return fiber.NewError(fiber.StatusConflict, "Kandang group tidak boleh dihapus karena masih memiliki relasi kandang")
}
@@ -37,6 +37,7 @@ import (
const (
chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif"
chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, Transfer, Adjustment, dan Transfer to Laying terlebih dahulu."
chickinAdjustmentSourceTable = "adjustment_stocks"
)
type ChickinService interface {
@@ -577,7 +578,7 @@ func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
AllocationPurpose: entity.StockAllocationPurposeConsume,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
AsOf: nil,
Limit: 10000,
Tx: tx,
})
@@ -586,10 +587,16 @@ func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx
}
available := 0.0
hasAsOf := asOf != nil && !asOf.IsZero()
for _, row := range gatherRows {
if row.AvailableQuantity <= 0 {
continue
}
if hasAsOf &&
!strings.EqualFold(strings.TrimSpace(row.SourceTable), chickinAdjustmentSourceTable) &&
row.SortAt.After(*asOf) {
continue
}
available += row.AvailableQuantity
}
return available, nil
@@ -18,6 +18,7 @@ type ProjectFlockPopulationRepository interface {
GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error)
GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, error)
ResyncUsageByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error
PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error
@@ -167,3 +168,58 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKand
return int64(math.Round(total)), nil
}
func (r *projectFlockPopulationRepositoryImpl) ResyncUsageByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
idsSubquery := `
SELECT pfp.id
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
`
updateWithAlloc := `
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
AND p.id IN (` + idsSubquery + `)
`
resetMissing := `
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.stockable_id = p.id
)
`
db := r.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
}
@@ -375,6 +375,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil {
return nil, err
}
depletionSourceIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint {
if d.SourceProductWarehouseId == nil {
return 0
}
return *d.SourceProductWarehouseId
})
if err := s.ensureProductWarehousesByFlags(ctx, depletionSourceIDs, []string{"DOC", "PULLET", "LAYER"}, "depletion source"); err != nil {
return nil, err
}
eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId })
if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil {
return nil, err
@@ -444,11 +453,16 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := s.ensureDepletionWithinPopulation(ctx, tx, req.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), 0); err != nil {
return err
}
sourceProjectFlockKandangID := req.ProjectFlockKandangId
for i := range mappedDepletions {
mappedDepletions[i].SourceProjectFlockKandangId = &sourceProjectFlockKandangID
if mappedDepletions[i].SourceProductWarehouseId != nil && *mappedDepletions[i].SourceProductWarehouseId != 0 {
continue
}
sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId)
if err != nil {
return err
}
for i := range mappedDepletions {
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
}
}
@@ -469,7 +483,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err
}
mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs)
mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.ProjectFlockKandangId, createdRecording.CreatedBy, req.Eggs)
if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil {
s.Log.Errorf("Failed to persist eggs: %+v", err)
return err
@@ -599,13 +613,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
s.Log.Errorf("Failed to list existing depletions: %+v", err)
return err
}
existingTotals := recordingutil.TotalsByWarehouse(existingDepletions, func(dep entity.RecordingDepletion) (uint, float64) {
return dep.ProductWarehouseId, dep.Qty
existingTotals := recordingutil.DepletionTotalsByRoute(existingDepletions, func(dep entity.RecordingDepletion) (uint, *uint, float64) {
return dep.ProductWarehouseId, dep.SourceProductWarehouseId, dep.Qty
})
incomingTotals := recordingutil.TotalsByWarehouse(req.Depletions, func(dep validation.Depletion) (uint, float64) {
return dep.ProductWarehouseId, dep.Qty
incomingTotals := recordingutil.DepletionTotalsByRoute(req.Depletions, func(dep validation.Depletion) (uint, *uint, float64) {
return dep.ProductWarehouseId, dep.SourceProductWarehouseId, dep.Qty
})
match := recordingutil.FloatMapsEqual(existingTotals, incomingTotals)
match := recordingutil.DepletionRouteMapsEqual(existingTotals, incomingTotals)
if match {
hasDepletionChanges = false
} else {
@@ -622,6 +636,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil {
return err
}
depletionSourceIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint {
if d.SourceProductWarehouseId == nil {
return 0
}
return *d.SourceProductWarehouseId
})
if err := s.ensureProductWarehousesByFlags(ctx, depletionSourceIDs, []string{"DOC", "PULLET", "LAYER"}, "depletion source"); err != nil {
return err
}
if err := s.reflowResetRecordingDepletionsOut(ctx, tx, existingDepletions, note, actorID); err != nil {
return err
}
@@ -639,11 +662,16 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil {
return err
}
sourceProjectFlockKandangID := recordingEntity.ProjectFlockKandangId
for i := range mappedDepletions {
mappedDepletions[i].SourceProjectFlockKandangId = &sourceProjectFlockKandangID
if mappedDepletions[i].SourceProductWarehouseId != nil && *mappedDepletions[i].SourceProductWarehouseId != 0 {
continue
}
sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId)
if err != nil {
return err
}
for i := range mappedDepletions {
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
}
}
@@ -709,7 +737,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err
}
mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs)
mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.ProjectFlockKandangId, recordingEntity.CreatedBy, req.Eggs)
if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil {
s.Log.Errorf("Failed to update eggs: %+v", err)
return err
@@ -1548,6 +1576,9 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v
if dep.ProductWarehouseId != 0 {
idSet[dep.ProductWarehouseId] = struct{}{}
}
if dep.SourceProductWarehouseId != nil && *dep.SourceProductWarehouseId != 0 {
idSet[*dep.SourceProductWarehouseId] = struct{}{}
}
}
for _, egg := range eggs {
if egg.ProductWarehouseId != 0 {
@@ -2572,6 +2603,10 @@ func (s *recordingService) allocatePopulationForDepletion(
}
var projectFlockKandangID uint
if depletion.SourceProjectFlockKandangId != nil {
projectFlockKandangID = *depletion.SourceProjectFlockKandangId
}
if projectFlockKandangID == 0 {
if err := tx.WithContext(ctx).
Table("recordings").
Select("project_flock_kandangs_id").
@@ -2579,6 +2614,7 @@ func (s *recordingService) allocatePopulationForDepletion(
Scan(&projectFlockKandangID).Error; err != nil {
return err
}
}
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak ditemukan untuk depletion")
}
@@ -10,6 +10,7 @@ type (
Depletion struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
SourceProductWarehouseId *uint `json:"source_product_warehouse_id,omitempty" validate:"omitempty,number,min=1"`
Qty float64 `json:"qty" validate:"required,gte=0"`
}
@@ -143,6 +143,17 @@ func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uin
return r.DB().WithContext(ctx).Create(&items).Error
}
func (r *PurchaseRepositoryImpl) purchaseItemExists(ctx context.Context, purchaseID uint, itemID uint) (bool, error) {
var count int64
if err := r.DB().WithContext(ctx).
Model(&entity.PurchaseItem{}).
Where("purchase_id = ? AND id = ?", purchaseID, itemID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
type PurchasePricingUpdate struct {
ItemID uint
ProductID *uint
@@ -197,9 +208,15 @@ func (r *PurchaseRepositoryImpl) UpdatePricing(
return result.Error
}
if result.RowsAffected == 0 {
exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID)
if err != nil {
return err
}
if !exists {
return gorm.ErrRecordNotFound
}
}
}
return nil
}
@@ -251,9 +268,15 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
return result.Error
}
if result.RowsAffected == 0 {
exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID)
if err != nil {
return err
}
if !exists {
return gorm.ErrRecordNotFound
}
}
}
return nil
}
@@ -0,0 +1,152 @@
package repositories
import (
"context"
"errors"
"testing"
"time"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
func TestUpdateReceivingDetailsAllowsNoOpUpdatesOnExistingItem(t *testing.T) {
db := setupPurchaseRepositoryTestDB(t)
repo := NewPurchaseRepository(db)
ctx := context.Background()
receivedAt := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
travelNumber := "SJ-001"
vehicleNumber := "B 1234 CD"
if err := db.WithContext(ctx).Create(&entity.PurchaseItem{
Id: 10,
PurchaseId: 1,
ProductId: 2,
WarehouseId: 3,
SubQty: 10,
TotalQty: 10,
Price: 15000,
TotalPrice: 150000,
ReceivedDate: &receivedAt,
TravelNumber: &travelNumber,
VehicleNumber: &vehicleNumber,
}).Error; err != nil {
t.Fatalf("failed seeding purchase item: %v", err)
}
pwID := uint(99)
if err := repo.UpdateReceivingDetails(ctx, 1, []PurchaseReceivingUpdate{
{
ItemID: 10,
ReceivedDate: &receivedAt,
TravelNumber: &travelNumber,
VehicleNumber: &vehicleNumber,
ProductWarehouseID: &pwID,
},
}); err != nil {
t.Fatalf("expected no-op receive update to succeed, got %v", err)
}
}
func TestUpdateReceivingDetailsReturnsNotFoundForMissingItem(t *testing.T) {
db := setupPurchaseRepositoryTestDB(t)
repo := NewPurchaseRepository(db)
ctx := context.Background()
receivedAt := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
err := repo.UpdateReceivingDetails(ctx, 1, []PurchaseReceivingUpdate{
{
ItemID: 999,
ReceivedDate: &receivedAt,
},
})
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("expected gorm.ErrRecordNotFound, got %v", err)
}
}
func TestUpdatePricingAllowsNoOpUpdatesOnExistingItem(t *testing.T) {
db := setupPurchaseRepositoryTestDB(t)
repo := NewPurchaseRepository(db)
ctx := context.Background()
if err := db.WithContext(ctx).Create(&entity.PurchaseItem{
Id: 20,
PurchaseId: 2,
ProductId: 5,
WarehouseId: 6,
SubQty: 5,
TotalQty: 5,
Price: 10000,
TotalPrice: 50000,
}).Error; err != nil {
t.Fatalf("failed seeding purchase item: %v", err)
}
if err := repo.UpdatePricing(ctx, 2, []PurchasePricingUpdate{
{
ItemID: 20,
Price: 10000,
TotalPrice: 50000,
},
}); err != nil {
t.Fatalf("expected no-op pricing update to succeed, got %v", err)
}
}
func TestUpdatePricingReturnsNotFoundForMissingItem(t *testing.T) {
db := setupPurchaseRepositoryTestDB(t)
repo := NewPurchaseRepository(db)
ctx := context.Background()
err := repo.UpdatePricing(ctx, 2, []PurchasePricingUpdate{
{
ItemID: 777,
Price: 10000,
TotalPrice: 50000,
},
})
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("expected gorm.ErrRecordNotFound, got %v", err)
}
}
func setupPurchaseRepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE purchase_items (
id INTEGER PRIMARY KEY,
purchase_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL,
received_date TIMESTAMP NULL,
travel_number TEXT NULL,
travel_number_docs TEXT NULL,
vehicle_number TEXT NULL,
sub_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
total_used NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
total_price NUMERIC(15,3) NOT NULL DEFAULT 0,
expense_nonstock_id INTEGER NULL
)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -2,12 +2,14 @@ package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
@@ -45,6 +47,17 @@ func reflowPurchaseScope(
AsOf: asOf,
Tx: tx,
})
if err != nil {
return err
}
_, err = fifoStockV2Svc.Recalculate(ctx, commonSvc.FifoStockV2RecalculateRequest{
ProductWarehouseIDs: []uint{productWarehouseID},
FlagGroupCodes: []string{flagGroupCode},
AsOf: asOf,
FixDrift: true,
Tx: tx,
})
return err
}
@@ -76,11 +89,53 @@ func resolvePurchaseFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB
Order("rr.id ASC").
Limit(1).
Take(&selected).Error
if err == nil {
return strings.TrimSpace(selected.FlagGroupCode), nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
type categoryRow struct {
CategoryCode string `gorm:"column:category_code"`
}
var category categoryRow
err = tx.WithContext(ctx).
Table("product_warehouses pw").
Select("pc.code AS category_code").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN product_categories pc ON pc.id = p.product_category_id").
Where("pw.id = ?", productWarehouseID).
Limit(1).
Take(&category).Error
if err != nil {
return "", err
}
return strings.TrimSpace(selected.FlagGroupCode), nil
flagGroupCode := utils.LegacyFlagGroupCodeByProductCategoryCode(category.CategoryCode)
if flagGroupCode == "" {
return "", gorm.ErrRecordNotFound
}
var matched int64
err = tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.lane = ?", purchaseStockableLane).
Where("rr.function_code = ?", purchaseInFunctionCode).
Where("rr.source_table = ?", purchaseSourceTable).
Where("rr.flag_group_code = ?", flagGroupCode).
Count(&matched).Error
if err != nil {
return "", err
}
if matched == 0 {
return "", gorm.ErrRecordNotFound
}
return flagGroupCode, nil
}
func assignEarliestAsOf(m map[uint]time.Time, productWarehouseID uint, asOf time.Time) {
@@ -0,0 +1,143 @@
package service
import (
"context"
"testing"
"github.com/glebarez/sqlite"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gorm.io/gorm"
)
func TestResolvePurchaseFlagGroupByProductWarehouseFallsBackToProductCategory(t *testing.T) {
db := setupPurchaseFifoHelperTestDB(t)
ctx := context.Background()
flagGroupCode, err := resolvePurchaseFlagGroupByProductWarehouse(ctx, db, 1115)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if flagGroupCode != "PAKAN" {
t.Fatalf("expected PAKAN, got %s", flagGroupCode)
}
}
func TestResolvePurchaseFlagGroupByProductWarehouseUsesProductFlagsWhenPresent(t *testing.T) {
db := setupPurchaseFifoHelperTestDB(t)
ctx := context.Background()
flagGroupCode, err := resolvePurchaseFlagGroupByProductWarehouse(ctx, db, 2222)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if flagGroupCode != "OVK" {
t.Fatalf("expected OVK, got %s", flagGroupCode)
}
}
func TestReflowPurchaseScopeRunsRecalculateToFixWarehouseDrift(t *testing.T) {
db := setupPurchaseFifoHelperTestDB(t)
ctx := context.Background()
fifo := &purchaseFifoStub{}
if err := reflowPurchaseScope(ctx, fifo, db, 1115, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fifo.reflowReqs) != 1 {
t.Fatalf("expected 1 reflow request, got %d", len(fifo.reflowReqs))
}
reflowReq := fifo.reflowReqs[0]
if reflowReq.ProductWarehouseID != 1115 {
t.Fatalf("expected reflow product warehouse 1115, got %d", reflowReq.ProductWarehouseID)
}
if reflowReq.FlagGroupCode != "PAKAN" {
t.Fatalf("expected reflow flag group PAKAN, got %s", reflowReq.FlagGroupCode)
}
if len(fifo.recalculateReqs) != 1 {
t.Fatalf("expected 1 recalculate request, got %d", len(fifo.recalculateReqs))
}
recalculateReq := fifo.recalculateReqs[0]
if len(recalculateReq.ProductWarehouseIDs) != 1 || recalculateReq.ProductWarehouseIDs[0] != 1115 {
t.Fatalf("expected recalculate for warehouse 1115, got %+v", recalculateReq.ProductWarehouseIDs)
}
if len(recalculateReq.FlagGroupCodes) != 1 || recalculateReq.FlagGroupCodes[0] != "PAKAN" {
t.Fatalf("expected recalculate for PAKAN, got %+v", recalculateReq.FlagGroupCodes)
}
if !recalculateReq.FixDrift {
t.Fatalf("expected recalculate FixDrift=true")
}
}
type purchaseFifoStub struct {
reflowReqs []commonSvc.FifoStockV2ReflowRequest
recalculateReqs []commonSvc.FifoStockV2RecalculateRequest
}
func (s *purchaseFifoStub) Gather(context.Context, commonSvc.FifoStockV2GatherRequest) ([]commonSvc.FifoStockV2GatherRow, error) {
return nil, nil
}
func (s *purchaseFifoStub) Allocate(context.Context, commonSvc.FifoStockV2AllocateRequest) (*commonSvc.FifoStockV2AllocateResult, error) {
return nil, nil
}
func (s *purchaseFifoStub) Rollback(context.Context, commonSvc.FifoStockV2RollbackRequest) (*commonSvc.FifoStockV2RollbackResult, error) {
return nil, nil
}
func (s *purchaseFifoStub) Reflow(_ context.Context, req commonSvc.FifoStockV2ReflowRequest) (*commonSvc.FifoStockV2ReflowResult, error) {
s.reflowReqs = append(s.reflowReqs, req)
return &commonSvc.FifoStockV2ReflowResult{}, nil
}
func (s *purchaseFifoStub) Recalculate(_ context.Context, req commonSvc.FifoStockV2RecalculateRequest) (*commonSvc.FifoStockV2RecalculateResult, error) {
s.recalculateReqs = append(s.recalculateReqs, req)
return &commonSvc.FifoStockV2RecalculateResult{}, nil
}
func setupPurchaseFifoHelperTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE fifo_stock_v2_flag_groups (code TEXT PRIMARY KEY, is_active BOOLEAN NOT NULL)`,
`CREATE TABLE fifo_stock_v2_flag_members (flag_name TEXT NOT NULL, flag_group_code TEXT NOT NULL, is_active BOOLEAN NOT NULL)`,
`CREATE TABLE fifo_stock_v2_route_rules (
id INTEGER PRIMARY KEY,
flag_group_code TEXT NOT NULL,
lane TEXT NOT NULL,
function_code TEXT NOT NULL,
source_table TEXT NOT NULL,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE product_categories (id INTEGER PRIMARY KEY, code TEXT NOT NULL)`,
`CREATE TABLE products (id INTEGER PRIMARY KEY, product_category_id INTEGER NOT NULL)`,
`CREATE TABLE flags (id INTEGER PRIMARY KEY, flagable_id INTEGER NOT NULL, flagable_type TEXT NOT NULL, name TEXT NOT NULL)`,
`CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL)`,
`INSERT INTO fifo_stock_v2_flag_groups (code, is_active) VALUES ('PAKAN', TRUE), ('OVK', TRUE)`,
`INSERT INTO fifo_stock_v2_flag_members (flag_name, flag_group_code, is_active) VALUES ('PAKAN', 'PAKAN', TRUE), ('OVK', 'OVK', TRUE), ('OBAT', 'OVK', TRUE)`,
`INSERT INTO fifo_stock_v2_route_rules (id, flag_group_code, lane, function_code, source_table, is_active) VALUES
(1, 'PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', TRUE),
(2, 'OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', TRUE)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'RAW'), (2, 'OBT')`,
`INSERT INTO products (id, product_category_id) VALUES (37, 1), (112, 2)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES
(1, 112, 'products', 'OVK'),
(2, 112, 'products', 'OBAT')`,
`INSERT INTO product_warehouses (id, product_id) VALUES (1115, 37), (2222, 112)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -53,17 +53,18 @@ type ProductRelationDTOFixed struct {
SellingPrice *float64 `json:"selling_price,omitempty"`
}
func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64, agingMap map[int]int) []RepportMarketingItemDTO {
func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppByDelivery map[uint]float64, categoryByDelivery map[uint]string, agingMap map[int]int) []RepportMarketingItemDTO {
items := make([]RepportMarketingItemDTO, 0, len(mdps))
for _, mdp := range mdps {
hppPerKg := float64(0)
category := ""
if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil {
if hpp, exists := hppMap[projectFlockKandang.ProjectFlockId]; exists {
hppPerKg = hpp
}
hppPerKg := hppByDelivery[mdp.Id]
category := categoryByDelivery[mdp.Id]
if category == "" {
if projectFlockKandang := mdp.AttributedProjectFlockKandang; projectFlockKandang != nil {
category = projectFlockKandang.ProjectFlock.Category
} else if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil {
category = projectFlockKandang.ProjectFlock.Category
}
}
soDate := time.Time{}
@@ -18,6 +18,7 @@ import (
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
@@ -215,41 +216,97 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing
}
}
projectFlockIDMap := make(map[uint]bool)
hppMap := make(map[uint]float64)
deliveryIDs := make([]uint, 0, len(deliveryProducts))
for _, delivery := range deliveryProducts {
deliveryIDs = append(deliveryIDs, delivery.Id)
}
for _, dp := range deliveryProducts {
if projectFlockKandang := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil {
projectFlockID := projectFlockKandang.ProjectFlockId
if projectFlockID > 0 && !projectFlockIDMap[projectFlockID] {
projectFlockIDMap[projectFlockID] = true
category := projectFlockKandang.ProjectFlock.Category
if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryLaying {
if s.HppSvc != nil {
hppCost, err := s.HppSvc.CalculateHppCost(projectFlockID, nil)
attributionRows, err := s.MarketingDeliveryRepo.GetAttributionRowsByDeliveryProductIDs(c.Context(), deliveryIDs)
if err != nil {
hppMap[projectFlockID] = 0.0
} else if hppCost != nil {
hppMap[projectFlockID] = hppCost.Real.HargaKg
} else {
hppMap[projectFlockID] = 0.0
}
} else {
hppMap[projectFlockID] = 0.0
}
} else {
hppMap[projectFlockID] = 0.0
}
}
}
return nil, 0, err
}
items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap)
hppByDelivery := buildMarketingHppByDelivery(c.Context(), s.HppSvc, attributionRows)
categoryByDelivery := buildMarketingCategoryByDelivery(deliveryProducts, attributionRows)
items := dto.ToMarketingReportItems(deliveryProducts, hppByDelivery, categoryByDelivery, agingMap)
return items, total, nil
}
func buildMarketingHppByDelivery(
ctx context.Context,
hppSvc approvalService.HppService,
attributionRows []commonRepo.MarketingDeliveryAttributionRow,
) map[uint]float64 {
if len(attributionRows) == 0 {
return map[uint]float64{}
}
hppByKandang := make(map[uint]float64)
weightedByDelivery := make(map[uint]float64)
totalQtyByDelivery := make(map[uint]float64)
for _, row := range attributionRows {
if row.MarketingDeliveryProductID == 0 || row.ProjectFlockKandangID == 0 || row.AllocatedQty <= 0 {
continue
}
hppPerKg, exists := hppByKandang[row.ProjectFlockKandangID]
if !exists {
hppPerKg = 0
if hppSvc != nil && utils.ProjectFlockCategory(row.ProjectFlockCategory) == utils.ProjectFlockCategoryLaying {
if hppCost, err := hppSvc.CalculateHppCost(row.ProjectFlockKandangID, nil); err == nil && hppCost != nil {
hppPerKg = hppCost.Real.HargaKg
}
}
hppByKandang[row.ProjectFlockKandangID] = hppPerKg
}
weightedByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty * hppPerKg
totalQtyByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty
}
result := make(map[uint]float64, len(totalQtyByDelivery))
for deliveryID, totalQty := range totalQtyByDelivery {
if totalQty <= 0 {
continue
}
result[deliveryID] = weightedByDelivery[deliveryID] / totalQty
}
return result
}
func buildMarketingCategoryByDelivery(
deliveryProducts []entity.MarketingDeliveryProduct,
attributionRows []commonRepo.MarketingDeliveryAttributionRow,
) map[uint]string {
result := make(map[uint]string, len(deliveryProducts))
for _, row := range attributionRows {
if row.MarketingDeliveryProductID == 0 || strings.TrimSpace(row.ProjectFlockCategory) == "" {
continue
}
if _, exists := result[row.MarketingDeliveryProductID]; !exists {
result[row.MarketingDeliveryProductID] = row.ProjectFlockCategory
}
}
for _, delivery := range deliveryProducts {
if _, exists := result[delivery.Id]; exists {
continue
}
if delivery.AttributedProjectFlockKandang != nil {
result[delivery.Id] = delivery.AttributedProjectFlockKandang.ProjectFlock.Category
continue
}
if delivery.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
result[delivery.Id] = delivery.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
}
}
return result
}
func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
+60
View File
@@ -1,6 +1,7 @@
package utils
import (
"slices"
"strings"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
@@ -135,6 +136,19 @@ var productAllowWithoutSubFlagByFlag = map[FlagType]bool{
FlagTelur: false,
}
var legacyProductCategoryFlagsByCode = map[string][]FlagType{
"DOC": {FlagAyam, FlagDOC},
"PLT": {FlagAyam, FlagPullet},
"EGG": {FlagTelur},
"RAW": {FlagPakan},
"PST": {FlagPakan, FlagPreStarter},
"STR": {FlagPakan, FlagStarter},
"FSR": {FlagPakan, FlagFinisher},
"OBT": {FlagOVK, FlagObat},
"VTM": {FlagOVK, FlagVitamin},
"KMA": {FlagOVK, FlagKimia},
}
var legacyFlagTypeAliases = map[FlagType]FlagType{
FlagDOC: FlagAyam,
FlagPullet: FlagAyam,
@@ -228,6 +242,52 @@ func ProductFlagAllowWithoutSubFlag(flag FlagType) bool {
return allow
}
func LegacyProductCategoryCodesForFlags(flags []string) []string {
if len(flags) == 0 {
return nil
}
requested := make(map[FlagType]struct{}, len(flags))
for _, flag := range flags {
canonical := CanonicalFlagType(flag)
if canonical == "" {
continue
}
requested[canonical] = struct{}{}
}
if len(requested) == 0 {
return nil
}
codes := make([]string, 0, len(legacyProductCategoryFlagsByCode))
for code, supportedFlags := range legacyProductCategoryFlagsByCode {
for _, supportedFlag := range supportedFlags {
if _, ok := requested[canonicalizeFlagType(supportedFlag)]; ok {
codes = append(codes, code)
break
}
}
}
slices.Sort(codes)
return codes
}
func LegacyFlagGroupCodeByProductCategoryCode(code string) string {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "DOC", "PLT":
return "AYAM"
case "EGG":
return "TELUR"
case "RAW", "PST", "STR", "FSR":
return "PAKAN"
case "OBT", "VTM", "KMA":
return "OVK"
default:
return ""
}
}
func IsProductMainFlag(flag FlagType) bool {
canonical := canonicalizeFlagType(flag)
for _, f := range productMainFlags {
+57 -5
View File
@@ -31,41 +31,62 @@ func MapDepletions(recordingID uint, items []validation.Depletion) []entity.Reco
return nil
}
aggregate := make(map[uint]float64, len(items))
type depletionKey struct {
ProductWarehouseID uint
SourceProductWarehouseID uint
}
aggregate := make(map[depletionKey]float64, len(items))
for _, item := range items {
if item.ProductWarehouseId == 0 || item.Qty == 0 {
continue
}
aggregate[item.ProductWarehouseId] += item.Qty
key := depletionKey{ProductWarehouseID: item.ProductWarehouseId}
if item.SourceProductWarehouseId != nil {
key.SourceProductWarehouseID = *item.SourceProductWarehouseId
}
aggregate[key] += item.Qty
}
if len(aggregate) == 0 {
return nil
}
result := make([]entity.RecordingDepletion, 0, len(aggregate))
for warehouseID, qty := range aggregate {
for key, qty := range aggregate {
if qty == 0 {
continue
}
var sourceWarehouseID *uint
if key.SourceProductWarehouseID != 0 {
sourceWarehouseID = new(uint)
*sourceWarehouseID = key.SourceProductWarehouseID
}
result = append(result, entity.RecordingDepletion{
RecordingId: recordingID,
ProductWarehouseId: warehouseID,
ProductWarehouseId: key.ProductWarehouseID,
SourceProductWarehouseId: sourceWarehouseID,
Qty: qty,
})
}
return result
}
func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity.RecordingEgg {
func MapEggs(recordingID uint, projectFlockKandangID uint, createdBy uint, items []validation.Egg) []entity.RecordingEgg {
if len(items) == 0 {
return nil
}
result := make([]entity.RecordingEgg, 0, len(items))
for _, item := range items {
var sourceProjectFlockKandangID *uint
if projectFlockKandangID != 0 {
sourceProjectFlockKandangID = new(uint)
*sourceProjectFlockKandangID = projectFlockKandangID
}
result = append(result, entity.RecordingEgg{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
ProjectFlockKandangId: sourceProjectFlockKandangID,
Qty: item.Qty,
Weight: item.Weight,
CreatedBy: createdBy,
@@ -79,6 +100,11 @@ type EggTotals struct {
Weight float64
}
type DepletionRoute struct {
ProductWarehouseId uint
SourceProductWarehouseId uint
}
func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 {
return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) {
var usage float64
@@ -121,6 +147,19 @@ func EggTotalsEqual(a, b map[uint]EggTotals) bool {
return true
}
func DepletionRouteMapsEqual(a, b map[DepletionRoute]float64) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
other, ok := b[key]
if !ok || !floatNearlyEqual(value, other) {
return false
}
}
return true
}
func floatNearlyEqual(a, b float64) bool {
return a-b <= 0.000001 && b-a <= 0.000001
}
@@ -134,6 +173,19 @@ func TotalsByWarehouse[T any](items []T, get func(T) (uint, float64)) map[uint]f
return result
}
func DepletionTotalsByRoute[T any](items []T, get func(T) (uint, *uint, float64)) map[DepletionRoute]float64 {
result := make(map[DepletionRoute]float64)
for _, item := range items {
productWarehouseID, sourceProductWarehouseID, qty := get(item)
key := DepletionRoute{ProductWarehouseId: productWarehouseID}
if sourceProductWarehouseID != nil {
key.SourceProductWarehouseId = *sourceProductWarehouseID
}
result[key] += qty
}
return result
}
func EggTotalsByWarehouse[T any](items []T, get func(T) (uint, int, *float64)) map[uint]EggTotals {
result := make(map[uint]EggTotals)
for _, item := range items {
@@ -0,0 +1,47 @@
package recording
import (
"testing"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
)
func TestMapDepletionsKeepsSourceWarehouseRoutes(t *testing.T) {
sourceA := uint(11)
sourceB := uint(12)
got := MapDepletions(99, []validation.Depletion{
{ProductWarehouseId: 21, SourceProductWarehouseId: &sourceA, Qty: 3},
{ProductWarehouseId: 21, SourceProductWarehouseId: &sourceA, Qty: 2},
{ProductWarehouseId: 21, SourceProductWarehouseId: &sourceB, Qty: 4},
})
if len(got) != 2 {
t.Fatalf("expected 2 depletion routes, got %d", len(got))
}
routeQty := DepletionTotalsByRoute(got, func(item entity.RecordingDepletion) (uint, *uint, float64) {
return item.ProductWarehouseId, item.SourceProductWarehouseId, item.Qty
})
if routeQty[DepletionRoute{ProductWarehouseId: 21, SourceProductWarehouseId: sourceA}] != 5 {
t.Fatalf("expected source A qty 5, got %.2f", routeQty[DepletionRoute{ProductWarehouseId: 21, SourceProductWarehouseId: sourceA}])
}
if routeQty[DepletionRoute{ProductWarehouseId: 21, SourceProductWarehouseId: sourceB}] != 4 {
t.Fatalf("expected source B qty 4, got %.2f", routeQty[DepletionRoute{ProductWarehouseId: 21, SourceProductWarehouseId: sourceB}])
}
}
func TestMapEggsSetsProjectFlockKandangID(t *testing.T) {
got := MapEggs(77, 44, 9, []validation.Egg{
{ProductWarehouseId: 88, Qty: 10},
})
if len(got) != 1 {
t.Fatalf("expected 1 egg row, got %d", len(got))
}
if got[0].ProjectFlockKandangId == nil || *got[0].ProjectFlockKandangId != 44 {
t.Fatalf("expected project flock kandang id 44, got %+v", got[0].ProjectFlockKandangId)
}
}