mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/filter-purchase
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
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
|
||||||
|
}
|
||||||
+18
@@ -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;
|
||||||
+61
@@ -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"`
|
Id uint `gorm:"primaryKey;autoIncrement"`
|
||||||
MarketingProductId uint `gorm:"uniqueIndex;not null"`
|
MarketingProductId uint `gorm:"uniqueIndex;not null"`
|
||||||
ProductWarehouseId uint `gorm:"not null"`
|
ProductWarehouseId uint `gorm:"not null"`
|
||||||
|
AttributedProjectFlockKandangId *uint `gorm:"->;column:attributed_project_flock_kandang_id"`
|
||||||
UnitPrice float64 `gorm:"type:numeric(15,3)"`
|
UnitPrice float64 `gorm:"type:numeric(15,3)"`
|
||||||
TotalWeight float64 `gorm:"type:numeric(15,3)"`
|
TotalWeight float64 `gorm:"type:numeric(15,3)"`
|
||||||
AvgWeight 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"`
|
CreatedAt *time.Time `gorm:"type:timestamptz;not null"`
|
||||||
|
|
||||||
MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"`
|
MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"`
|
||||||
|
AttributedProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:AttributedProjectFlockKandangId;references:Id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ type RecordingDepletion struct {
|
|||||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||||
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
|
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
|
||||||
|
SourceProjectFlockKandangId *uint `gorm:"column:source_project_flock_kandang_id"`
|
||||||
Qty float64 `gorm:"column:qty;not null"`
|
Qty float64 `gorm:"column:qty;not null"`
|
||||||
UsageQty float64 `gorm:"column:usage_qty"`
|
UsageQty float64 `gorm:"column:usage_qty"`
|
||||||
PendingQty float64 `gorm:"column:pending_qty"`
|
PendingQty float64 `gorm:"column:pending_qty"`
|
||||||
|
|
||||||
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
|
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
|
||||||
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||||
|
SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type RecordingEgg struct {
|
|||||||
Id uint `gorm:"primaryKey"`
|
Id uint `gorm:"primaryKey"`
|
||||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||||
|
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
|
||||||
Qty int `gorm:"column:qty;not null"`
|
Qty int `gorm:"column:qty;not null"`
|
||||||
TotalQty float64 `gorm:"column:total_qty"`
|
TotalQty float64 `gorm:"column:total_qty"`
|
||||||
TotalUsed float64 `gorm:"column:total_used"`
|
TotalUsed float64 `gorm:"column:total_used"`
|
||||||
@@ -14,6 +15,7 @@ type RecordingEgg struct {
|
|||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||||
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||||
|
ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
|
||||||
ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"`
|
ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"`
|
||||||
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||||
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
|
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type PenjualanRealisasiResponseDTO struct {
|
|||||||
// === Mapper Functions ===
|
// === Mapper Functions ===
|
||||||
|
|
||||||
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
|
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
|
||||||
|
projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e)
|
||||||
|
|
||||||
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
|
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
|
||||||
for i, f := range 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
|
var category string
|
||||||
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
|
if projectFlockKandang != nil {
|
||||||
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
|
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
|
var product *productDTO.ProductRelationDTO
|
||||||
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
|
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
|
||||||
@@ -70,8 +71,8 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var kandang *kandangDTO.KandangRelationDTO
|
var kandang *kandangDTO.KandangRelationDTO
|
||||||
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 {
|
if projectFlockKandang != nil && projectFlockKandang.Kandang.Id != 0 {
|
||||||
mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang)
|
mapped := kandangDTO.ToKandangRelationDTO(projectFlockKandang.Kandang)
|
||||||
kandang = &mapped
|
kandang = &mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
|
func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
|
||||||
|
projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e)
|
||||||
|
|
||||||
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
|
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
|
||||||
for i, f := range 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
|
var category string
|
||||||
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
|
if projectFlockKandang != nil {
|
||||||
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
|
category = projectFlockKandang.ProjectFlock.Category
|
||||||
}
|
}
|
||||||
|
|
||||||
ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
|
ageInDay, _ := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category)
|
||||||
|
|
||||||
return SalesDTO{
|
return SalesDTO{
|
||||||
Age: ageInDay,
|
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) {
|
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) {
|
||||||
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
|
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ type ClosingRepository interface {
|
|||||||
SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error)
|
SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error)
|
||||||
SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error)
|
SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error)
|
||||||
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
|
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
|
||||||
FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error)
|
FetchSapronakIncoming(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error)
|
||||||
FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, 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)
|
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)
|
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)
|
FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error)
|
||||||
@@ -90,6 +90,23 @@ type SapronakQueryParams struct {
|
|||||||
EndDate *time.Time
|
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) {
|
func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) {
|
||||||
db := r.DB().WithContext(ctx)
|
db := r.DB().WithContext(ctx)
|
||||||
|
|
||||||
@@ -103,8 +120,10 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
|
|||||||
if len(params.WarehouseIDs) == 0 {
|
if len(params.WarehouseIDs) == 0 {
|
||||||
return []SapronakRow{}, 0, nil
|
return []SapronakRow{}, 0, nil
|
||||||
}
|
}
|
||||||
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
|
purchasesSQL, purchaseArgs := sapronakIncomingPurchaseQueryParts(params)
|
||||||
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
|
unionParts = append(unionParts, purchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
|
||||||
|
args = append(args, purchaseArgs...)
|
||||||
|
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
|
||||||
case validation.SapronakTypeOutgoing:
|
case validation.SapronakTypeOutgoing:
|
||||||
if len(params.WarehouseIDs) > 0 {
|
if len(params.WarehouseIDs) > 0 {
|
||||||
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
|
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
|
||||||
@@ -193,8 +212,10 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S
|
|||||||
if len(params.WarehouseIDs) == 0 {
|
if len(params.WarehouseIDs) == 0 {
|
||||||
return []SapronakSummaryRow{}, nil
|
return []SapronakSummaryRow{}, nil
|
||||||
}
|
}
|
||||||
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
|
purchasesSQL, purchaseArgs := sapronakIncomingPurchaseQueryParts(params)
|
||||||
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
|
unionParts = append(unionParts, purchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
|
||||||
|
args = append(args, purchaseArgs...)
|
||||||
|
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
|
||||||
case validation.SapronakTypeOutgoing:
|
case validation.SapronakTypeOutgoing:
|
||||||
if len(params.WarehouseIDs) > 0 {
|
if len(params.WarehouseIDs) > 0 {
|
||||||
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
|
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
|
||||||
@@ -298,10 +319,11 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c
|
|||||||
|
|
||||||
err = r.DB().WithContext(ctx).
|
err = r.DB().WithContext(ctx).
|
||||||
Table("recording_stocks rs").
|
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 product_warehouses pw ON pw.id = rs.product_warehouse_id").
|
||||||
Joins("JOIN products prod ON prod.id = pw.product_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 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").
|
Where("f.name = ?", "PAKAN").
|
||||||
Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used").
|
Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used").
|
||||||
Scan(&usageAgg).Error
|
Scan(&usageAgg).Error
|
||||||
@@ -340,10 +362,11 @@ func (r *ClosingRepositoryImpl) SumClaimCullingByProjectFlockKandangIDs(ctx cont
|
|||||||
|
|
||||||
err := r.DB().WithContext(ctx).
|
err := r.DB().WithContext(ctx).
|
||||||
Table("recording_depletions rd").
|
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 product_warehouses pw ON pw.id = rd.product_warehouse_id").
|
||||||
Joins("JOIN products prod ON prod.id = pw.product_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 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).
|
Where("f.name = ?", utils.FlagAyamCulling).
|
||||||
Select("COALESCE(SUM(rd.qty), 0) AS total_culling").
|
Select("COALESCE(SUM(rd.qty), 0) AS total_culling").
|
||||||
Scan(&agg).Error
|
Scan(&agg).Error
|
||||||
@@ -358,52 +381,14 @@ func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs
|
|||||||
if len(projectFlockKandangIDs) == 0 {
|
if len(projectFlockKandangIDs) == 0 {
|
||||||
return 0, 0, 0, nil
|
return 0, 0, 0, nil
|
||||||
}
|
}
|
||||||
|
return r.sumMarketingAttributedByProjectFlockKandangIDs(ctx, projectFlockKandangIDs, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) {
|
func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) {
|
||||||
if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 {
|
if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 {
|
||||||
return 0, 0, 0, nil
|
return 0, 0, 0, nil
|
||||||
}
|
}
|
||||||
|
return r.sumMarketingAttributedByProjectFlockKandangIDs(ctx, projectFlockKandangIDs, flagNames)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) {
|
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).
|
err := r.DB().WithContext(ctx).
|
||||||
Table("recording_eggs re").
|
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 product_warehouses pw ON pw.id = re.product_warehouse_id").
|
||||||
Joins("JOIN products prod ON prod.id = pw.product_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 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).
|
Where("f.name IN ?", flagNames).
|
||||||
Select("COALESCE(SUM(re.qty), 0) AS total_qty").
|
Select("COALESCE(SUM(re.qty), 0) AS total_qty").
|
||||||
Scan(&agg).Error
|
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) 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 {
|
func applyDateRange(db *gorm.DB, column string, start, end *time.Time) *gorm.DB {
|
||||||
if start != nil {
|
if start != nil {
|
||||||
db = db.Where(column+"::date >= ?", start)
|
db = db.Where(column+"::date >= ?", start)
|
||||||
@@ -844,6 +876,140 @@ func sapronakFlags(flags ...utils.FlagType) []string {
|
|||||||
return out
|
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 (
|
var (
|
||||||
sapronakFlagsAll = sapronakFlags(utils.FlagDOC, utils.FlagPakan, utils.FlagOVK, utils.FlagPullet)
|
sapronakFlagsAll = sapronakFlags(utils.FlagDOC, utils.FlagPakan, utils.FlagOVK, utils.FlagPullet)
|
||||||
sapronakFlagsUsage = sapronakFlags(utils.FlagPakan, utils.FlagOVK)
|
sapronakFlagsUsage = sapronakFlags(utils.FlagPakan, utils.FlagOVK)
|
||||||
@@ -851,18 +1017,44 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB {
|
func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB {
|
||||||
subquery := r.DB().
|
actualFlags := r.DB().
|
||||||
Table("flags").
|
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("flagable_type = ?", entity.FlagableTypeProduct).
|
||||||
Where("name IN ?", sapronakFlagsAll).
|
Where("UPPER(name) IN ?", sapronakFlagsAll).
|
||||||
Order(fmt.Sprintf(
|
Group("flagable_id")
|
||||||
"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",
|
|
||||||
|
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.FlagDOC,
|
||||||
utils.FlagPullet,
|
utils.FlagPullet,
|
||||||
utils.FlagPakan,
|
utils.FlagPakan,
|
||||||
utils.FlagOVK,
|
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)
|
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)
|
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).
|
db := r.withCtx(ctx).
|
||||||
Table("purchase_items AS pi").
|
Table("purchase_items AS pi").
|
||||||
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
|
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 products p ON p.id = pi.product_id").
|
||||||
Joins("JOIN warehouses w ON w.id = pi.warehouse_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("f.name IN ?", sapronakFlagsAll).
|
||||||
Where("pi.received_date IS NOT NULL")
|
Where("pi.received_date IS NOT NULL")
|
||||||
db = applyDateRange(db, "pi.received_date", start, end)
|
db = applyDateRange(db, "pi.received_date", start, end)
|
||||||
return r.joinSapronakProductFlag(db, "p")
|
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] = ©Row
|
||||||
|
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)
|
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,
|
pi.product_id AS product_id,
|
||||||
p.name AS product_name,
|
p.name AS product_name,
|
||||||
f.name AS flag,
|
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 {
|
if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if projectFlockKandangID == 0 {
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
|
farmRows := make([]SapronakIncomingRow, 0)
|
||||||
return scanAndGroupDetails(
|
farmDB := r.incomingFarmPurchaseAllocationBase(ctx, projectFlockKandangID, start, end).Select(`
|
||||||
r.incomingPurchaseBase(ctx, kandangID, 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,
|
pi.product_id AS product_id,
|
||||||
p.name AS product_name,
|
p.name AS product_name,
|
||||||
f.name AS flag,
|
f.name AS flag,
|
||||||
@@ -1163,6 +1462,34 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context
|
|||||||
COALESCE(pi.price,0) AS price
|
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 {
|
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) {
|
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).
|
query := r.withCtx(ctx).
|
||||||
Table("stock_allocations AS sa").
|
Table("stock_allocations AS sa").
|
||||||
Select(`
|
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 marketings m ON m.id = mp.marketing_id").
|
||||||
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
|
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
|
||||||
Joins("JOIN products p ON p.id = pw.product_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.status = ?", entity.StockAllocationStatusActive).
|
||||||
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||||
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
|
Where(attributedProjectFlockKandangExpr+" = ?", projectFlockKandangID).
|
||||||
Where("f.name IN ?", sapronakFlagsAll).
|
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")
|
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
|
END
|
||||||
`, pfpType)
|
`, 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).
|
query := r.withCtx(ctx).
|
||||||
Table("stock_allocations AS sa").
|
Table("stock_allocations AS sa").
|
||||||
Select(fmt.Sprintf(`
|
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 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_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 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_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 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").
|
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.status = ?", entity.StockAllocationStatusActive).
|
||||||
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||||
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
|
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
|
||||||
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
|
Where(attributedProjectFlockKandangExpr+" = ?", projectFlockKandangID).
|
||||||
Where("f.name IN ?", sapronakFlagsAll).
|
Where("f.name IN ?", sapronakFlagsAll).
|
||||||
Group(`
|
Group(`
|
||||||
p_resolve.id, p_resolve.name, f.name,
|
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
|
var projectFlockKandangIDs []uint
|
||||||
if params.KandangID != nil && *params.KandangID > 0 {
|
if params.KandangID != nil && *params.KandangID > 0 {
|
||||||
projectFlockKandangIDs = []uint{*params.KandangID}
|
projectFlockKandangIDs = []uint{*params.KandangID}
|
||||||
} else if params.Type == validation.SapronakTypeOutgoing {
|
} else {
|
||||||
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
|
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
|
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
|
var projectFlockKandangIDs []uint
|
||||||
if params.KandangID != nil && *params.KandangID > 0 {
|
if params.KandangID != nil && *params.KandangID > 0 {
|
||||||
projectFlockKandangIDs = []uint{*params.KandangID}
|
projectFlockKandangIDs = []uint{*params.KandangID}
|
||||||
} else if params.Type == validation.SapronakTypeOutgoing {
|
} else {
|
||||||
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
|
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
|
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) {
|
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).
|
// Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any).
|
||||||
startDate, endDate := sapronakPeriodRange(pfk)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, 0, 0, err
|
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 {
|
if err != nil {
|
||||||
return nil, nil, 0, 0, err
|
return nil, nil, 0, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -29,6 +29,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
|
|||||||
Limit: c.QueryInt("limit", 10),
|
Limit: c.QueryInt("limit", 10),
|
||||||
ProductId: uint(c.QueryInt("product_id", 0)),
|
ProductId: uint(c.QueryInt("product_id", 0)),
|
||||||
WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
|
WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
|
||||||
|
LocationId: uint(c.QueryInt("location_id", 0)),
|
||||||
Flags: c.Query("flags", ""),
|
Flags: c.Query("flags", ""),
|
||||||
KandangId: uint(c.QueryInt("kandang_id", 0)),
|
KandangId: uint(c.QueryInt("kandang_id", 0)),
|
||||||
TransferContext: c.Query(utils.TransferContextKey, ""),
|
TransferContext: c.Query(utils.TransferContextKey, ""),
|
||||||
|
|||||||
+64
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
-35
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,31 +85,28 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) {
|
func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) {
|
||||||
var productWarehouse entity.ProductWarehouse
|
warehouseIsKandang, err := r.isKandangWarehouse(ctx, warehouseId)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.
|
fallbackCategoryCodes := utils.LegacyProductCategoryCodesForFlags(flags)
|
||||||
|
|
||||||
|
db = db.
|
||||||
Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id").
|
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").
|
Joins("LEFT JOIN product_categories pc_flag ON pc_flag.id = p_flag.product_category_id")
|
||||||
Where("f_flag.name IN ?", flags).
|
|
||||||
|
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()
|
Distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,18 +296,8 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse(
|
|||||||
projectFlockKandangID *uint,
|
projectFlockKandangID *uint,
|
||||||
createdBy uint,
|
createdBy uint,
|
||||||
) (uint, error) {
|
) (uint, error) {
|
||||||
record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID)
|
record, err := r.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID)
|
||||||
if err == nil {
|
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
|
return record.Id, nil
|
||||||
}
|
}
|
||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -301,6 +321,45 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse(
|
|||||||
return entity.Id, nil
|
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(
|
func (r *ProductWarehouseRepositoryImpl) GetByProductWarehouseAndProjectFlockKandang(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
productId uint,
|
productId uint,
|
||||||
|
|||||||
+190
@@ -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
|
||||||
|
}
|
||||||
+19
-4
@@ -53,6 +53,24 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
|
|||||||
Preload("ProjectFlockKandang.Chickins")
|
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) {
|
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
|
||||||
if err := s.Validate.Struct(params); err != nil {
|
if err := s.Validate.Struct(params); err != nil {
|
||||||
return nil, 0, err
|
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)
|
db = db.Where("product_id = ?", params.ProductId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.KandangId != 0 {
|
db = applyWarehouseSelectionFilter(db, params.KandangId, params.LocationId)
|
||||||
db = db.Joins("JOIN warehouses ON product_warehouses.warehouse_id = warehouses.id").
|
|
||||||
Where("warehouses.kandang_id = ?", params.KandangId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if params.WarehouseId != 0 {
|
if params.WarehouseId != 0 {
|
||||||
db = db.Where("warehouse_id = ?", params.WarehouseId)
|
db = db.Where("warehouse_id = ?", params.WarehouseId)
|
||||||
|
|||||||
+111
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -17,6 +17,7 @@ type Query struct {
|
|||||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
||||||
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
|
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
|
||||||
WarehouseId uint `query:"warehouse_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"`
|
Flags string `query:"flags" validate:"omitempty"`
|
||||||
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
|
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
|
||||||
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
|
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
|
||||||
|
|||||||
+232
-124
@@ -2,9 +2,10 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"strings"
|
"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"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type MarketingDeliveryProductRepository interface {
|
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)
|
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)
|
GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
|
||||||
GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]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)
|
GetUsageQty(ctx context.Context, id uint) (float64, error)
|
||||||
ResetFifoFields(ctx context.Context, id uint) error
|
ResetFifoFields(ctx context.Context, id uint) error
|
||||||
GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
|
GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
|
||||||
|
GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type MarketingDeliveryProductRepositoryImpl struct {
|
type MarketingDeliveryProductRepositoryImpl struct {
|
||||||
*repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct]
|
*commonRepo.BaseRepositoryImpl[entity.MarketingDeliveryProduct]
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository {
|
func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository {
|
||||||
return &MarketingDeliveryProductRepositoryImpl{
|
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) {
|
func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) {
|
||||||
var deliveryProducts []entity.MarketingDeliveryProduct
|
var deliveryProducts []entity.MarketingDeliveryProduct
|
||||||
|
|
||||||
|
attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))
|
||||||
|
|
||||||
db := 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 (?) AS mda ON mda.marketing_delivery_product_id = marketing_delivery_products.id", attributionQuery).
|
||||||
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
|
Where("mda.project_flock_id = ?", projectFlockID).
|
||||||
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
|
|
||||||
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
|
|
||||||
Distinct("marketing_delivery_products.*")
|
Distinct("marketing_delivery_products.*")
|
||||||
|
|
||||||
if callback != nil {
|
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) {
|
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
|
||||||
var deliveryProducts []entity.MarketingDeliveryProduct
|
attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, nil)
|
||||||
|
if err != nil {
|
||||||
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 {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID)
|
||||||
return deliveryProducts, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
|
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
|
||||||
var deliveryProducts []entity.MarketingDeliveryProduct
|
attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, []string{
|
||||||
|
|
||||||
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{
|
|
||||||
string(utils.FlagAyamAfkir),
|
string(utils.FlagAyamAfkir),
|
||||||
string(utils.FlagAyamCulling),
|
string(utils.FlagAyamCulling),
|
||||||
string(utils.FlagPullet),
|
string(utils.FlagPullet),
|
||||||
string(utils.FlagLayer),
|
string(utils.FlagLayer),
|
||||||
}).
|
})
|
||||||
Where("marketing_delivery_products.delivery_date IS NOT NULL").
|
if err != nil {
|
||||||
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 {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID)
|
||||||
return deliveryProducts, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) {
|
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) {
|
||||||
var deliveryProducts []entity.MarketingDeliveryProduct
|
flagNames := []string{
|
||||||
|
|
||||||
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{
|
|
||||||
string(utils.FlagDOC),
|
string(utils.FlagDOC),
|
||||||
string(utils.FlagPullet),
|
string(utils.FlagPullet),
|
||||||
string(utils.FlagLayer),
|
string(utils.FlagLayer),
|
||||||
string(utils.FlagAyamAfkir),
|
string(utils.FlagAyamAfkir),
|
||||||
string(utils.FlagAyamCulling),
|
string(utils.FlagAyamCulling),
|
||||||
string(utils.FlagAyamMati),
|
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.
|
attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, flagNames)
|
||||||
Preload("MarketingProduct").
|
if err != nil {
|
||||||
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 {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID)
|
||||||
return deliveryProducts, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) {
|
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
|
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) {
|
func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) {
|
||||||
var deliveryProducts []entity.MarketingDeliveryProduct
|
var deliveryProducts []entity.MarketingDeliveryProduct
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
|
baseDB := r.DB().WithContext(ctx)
|
||||||
|
singleAttributionQuery := commonRepo.MarketingDeliverySingleAttributionQuery(baseDB)
|
||||||
db := r.DB().WithContext(ctx).
|
db := r.DB().WithContext(ctx).
|
||||||
Model(&entity.MarketingDeliveryProduct{}).
|
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 {
|
Preload("MarketingProduct", func(db *gorm.DB) *gorm.DB {
|
||||||
return db.
|
return db.
|
||||||
Preload("Marketing").
|
Preload("Marketing").
|
||||||
@@ -237,6 +337,9 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
|
|||||||
Preload("ProductWarehouse.ProjectFlockKandang").
|
Preload("ProductWarehouse.ProjectFlockKandang").
|
||||||
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock")
|
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 marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
|
||||||
Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id").
|
Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id").
|
||||||
Where("marketing_delivery_products.delivery_date IS NOT NULL")
|
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 {
|
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").
|
buildAttrFilter := func() *gorm.DB {
|
||||||
Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id")
|
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 {
|
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 {
|
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 filters.AllowedAreaIDs != nil {
|
||||||
if len(filters.AllowedAreaIDs) == 0 {
|
if len(filters.AllowedAreaIDs) == 0 {
|
||||||
db = db.Where("1 = 0")
|
db = db.Where("1 = 0")
|
||||||
} else {
|
} 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 {
|
if len(filters.AllowedLocationIDs) == 0 {
|
||||||
db = db.Where("1 = 0")
|
db = db.Where("1 = 0")
|
||||||
} else {
|
} else {
|
||||||
db = db.Where("project_flocks.location_id IN ?", filters.AllowedLocationIDs)
|
db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.location_id IN ?", filters.AllowedLocationIDs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
@@ -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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
affectedKandangIDs, err := s.marketingPopulationKandangIDsFromActiveAllocations(ctx, tx, deliveryProduct.Id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
deliveryProduct.UsageQty = 0
|
deliveryProduct.UsageQty = 0
|
||||||
deliveryProduct.PendingQty = 0
|
deliveryProduct.PendingQty = 0
|
||||||
if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
|
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 {
|
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.resyncPopulationUsageByKandangIDs(ctx, tx, affectedKandangIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
releasedUsage := currentUsage - deliveryProduct.UsageQty
|
releasedUsage := currentUsage - deliveryProduct.UsageQty
|
||||||
if actorID > 0 && releasedUsage > 0 {
|
if actorID > 0 && releasedUsage > 0 {
|
||||||
@@ -725,29 +733,378 @@ func (s deliveryOrdersService) allocatePopulationForMarketingDelivery(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil)
|
exactAllocations, err := s.findDirectPopulationAllocationsForMarketing(ctx, tx, deliveryProduct.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 {
|
if len(exactAllocations) > 0 {
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(populations) == 0 {
|
if len(populations) == 0 {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery")
|
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery")
|
||||||
}
|
}
|
||||||
|
if err := s.allocatePopulationConsumptionWithoutRelease(
|
||||||
return fifoV2.AllocatePopulationConsumption(
|
|
||||||
ctx,
|
ctx,
|
||||||
tx,
|
tx,
|
||||||
populations,
|
populations,
|
||||||
productWarehouseID,
|
productWarehouseID,
|
||||||
fifo.UsableKeyMarketingDelivery.String(),
|
|
||||||
deliveryProduct.Id,
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ type KandangGroupRepository interface {
|
|||||||
LocationExists(ctx context.Context, locationId uint) (bool, error)
|
LocationExists(ctx context.Context, locationId uint) (bool, error)
|
||||||
PicExists(ctx context.Context, picId uint) (bool, error)
|
PicExists(ctx context.Context, picId uint) (bool, error)
|
||||||
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
|
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
|
||||||
|
HasDailyChecklistRelation(ctx context.Context, kandangGroupId uint) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type KandangGroupRepositoryImpl struct {
|
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) {
|
func (r *KandangGroupRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) {
|
||||||
return repository.ExistsByName[entity.KandangGroup](ctx, r.db, name, excludeID)
|
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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
if len(kandangGroup.Kandangs) > 0 {
|
||||||
return fiber.NewError(fiber.StatusConflict, "Kandang group tidak boleh dihapus karena masih memiliki relasi kandang")
|
return fiber.NewError(fiber.StatusConflict, "Kandang group tidak boleh dihapus karena masih memiliki relasi kandang")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif"
|
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."
|
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 {
|
type ChickinService interface {
|
||||||
@@ -577,7 +578,7 @@ func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx
|
|||||||
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
|
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
|
||||||
AllocationPurpose: entity.StockAllocationPurposeConsume,
|
AllocationPurpose: entity.StockAllocationPurposeConsume,
|
||||||
ProductWarehouseID: productWarehouseID,
|
ProductWarehouseID: productWarehouseID,
|
||||||
AsOf: asOf,
|
AsOf: nil,
|
||||||
Limit: 10000,
|
Limit: 10000,
|
||||||
Tx: tx,
|
Tx: tx,
|
||||||
})
|
})
|
||||||
@@ -586,10 +587,16 @@ func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx
|
|||||||
}
|
}
|
||||||
|
|
||||||
available := 0.0
|
available := 0.0
|
||||||
|
hasAsOf := asOf != nil && !asOf.IsZero()
|
||||||
for _, row := range gatherRows {
|
for _, row := range gatherRows {
|
||||||
if row.AvailableQuantity <= 0 {
|
if row.AvailableQuantity <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if hasAsOf &&
|
||||||
|
!strings.EqualFold(strings.TrimSpace(row.SourceTable), chickinAdjustmentSourceTable) &&
|
||||||
|
row.SortAt.After(*asOf) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
available += row.AvailableQuantity
|
available += row.AvailableQuantity
|
||||||
}
|
}
|
||||||
return available, nil
|
return available, nil
|
||||||
|
|||||||
+56
@@ -18,6 +18,7 @@ type ProjectFlockPopulationRepository interface {
|
|||||||
GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error)
|
GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error)
|
||||||
GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
|
GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
|
||||||
GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, 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
|
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
|
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
|
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 {
|
if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil {
|
||||||
return nil, err
|
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 })
|
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 {
|
if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil {
|
||||||
return nil, err
|
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 {
|
if err := s.ensureDepletionWithinPopulation(ctx, tx, req.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), 0); err != nil {
|
||||||
return err
|
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)
|
sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i := range mappedDepletions {
|
|
||||||
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
|
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,7 +483,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
|||||||
return err
|
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 {
|
if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil {
|
||||||
s.Log.Errorf("Failed to persist eggs: %+v", err)
|
s.Log.Errorf("Failed to persist eggs: %+v", err)
|
||||||
return 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)
|
s.Log.Errorf("Failed to list existing depletions: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
existingTotals := recordingutil.TotalsByWarehouse(existingDepletions, func(dep entity.RecordingDepletion) (uint, float64) {
|
existingTotals := recordingutil.DepletionTotalsByRoute(existingDepletions, func(dep entity.RecordingDepletion) (uint, *uint, float64) {
|
||||||
return dep.ProductWarehouseId, dep.Qty
|
return dep.ProductWarehouseId, dep.SourceProductWarehouseId, dep.Qty
|
||||||
})
|
})
|
||||||
incomingTotals := recordingutil.TotalsByWarehouse(req.Depletions, func(dep validation.Depletion) (uint, float64) {
|
incomingTotals := recordingutil.DepletionTotalsByRoute(req.Depletions, func(dep validation.Depletion) (uint, *uint, float64) {
|
||||||
return dep.ProductWarehouseId, dep.Qty
|
return dep.ProductWarehouseId, dep.SourceProductWarehouseId, dep.Qty
|
||||||
})
|
})
|
||||||
match := recordingutil.FloatMapsEqual(existingTotals, incomingTotals)
|
match := recordingutil.DepletionRouteMapsEqual(existingTotals, incomingTotals)
|
||||||
if match {
|
if match {
|
||||||
hasDepletionChanges = false
|
hasDepletionChanges = false
|
||||||
} else {
|
} 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 {
|
if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil {
|
||||||
return err
|
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 {
|
if err := s.reflowResetRecordingDepletionsOut(ctx, tx, existingDepletions, note, actorID); err != nil {
|
||||||
return err
|
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 {
|
if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil {
|
||||||
return err
|
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)
|
sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i := range mappedDepletions {
|
|
||||||
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
|
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,7 +737,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
return err
|
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 {
|
if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil {
|
||||||
s.Log.Errorf("Failed to update eggs: %+v", err)
|
s.Log.Errorf("Failed to update eggs: %+v", err)
|
||||||
return err
|
return err
|
||||||
@@ -1548,6 +1576,9 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v
|
|||||||
if dep.ProductWarehouseId != 0 {
|
if dep.ProductWarehouseId != 0 {
|
||||||
idSet[dep.ProductWarehouseId] = struct{}{}
|
idSet[dep.ProductWarehouseId] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if dep.SourceProductWarehouseId != nil && *dep.SourceProductWarehouseId != 0 {
|
||||||
|
idSet[*dep.SourceProductWarehouseId] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, egg := range eggs {
|
for _, egg := range eggs {
|
||||||
if egg.ProductWarehouseId != 0 {
|
if egg.ProductWarehouseId != 0 {
|
||||||
@@ -2572,6 +2603,10 @@ func (s *recordingService) allocatePopulationForDepletion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var projectFlockKandangID uint
|
var projectFlockKandangID uint
|
||||||
|
if depletion.SourceProjectFlockKandangId != nil {
|
||||||
|
projectFlockKandangID = *depletion.SourceProjectFlockKandangId
|
||||||
|
}
|
||||||
|
if projectFlockKandangID == 0 {
|
||||||
if err := tx.WithContext(ctx).
|
if err := tx.WithContext(ctx).
|
||||||
Table("recordings").
|
Table("recordings").
|
||||||
Select("project_flock_kandangs_id").
|
Select("project_flock_kandangs_id").
|
||||||
@@ -2579,6 +2614,7 @@ func (s *recordingService) allocatePopulationForDepletion(
|
|||||||
Scan(&projectFlockKandangID).Error; err != nil {
|
Scan(&projectFlockKandangID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if projectFlockKandangID == 0 {
|
if projectFlockKandangID == 0 {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak ditemukan untuk depletion")
|
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak ditemukan untuk depletion")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type (
|
|||||||
|
|
||||||
Depletion struct {
|
Depletion struct {
|
||||||
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
|
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"`
|
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
|
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 {
|
type PurchasePricingUpdate struct {
|
||||||
ItemID uint
|
ItemID uint
|
||||||
ProductID *uint
|
ProductID *uint
|
||||||
@@ -197,9 +208,15 @@ func (r *PurchaseRepositoryImpl) UpdatePricing(
|
|||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
|
exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
return gorm.ErrRecordNotFound
|
return gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -251,9 +268,15 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
|
|||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
|
exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
return gorm.ErrRecordNotFound
|
return gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +47,17 @@ func reflowPurchaseScope(
|
|||||||
AsOf: asOf,
|
AsOf: asOf,
|
||||||
Tx: tx,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +89,53 @@ func resolvePurchaseFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB
|
|||||||
Order("rr.id ASC").
|
Order("rr.id ASC").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Take(&selected).Error
|
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 {
|
if err != nil {
|
||||||
return "", err
|
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) {
|
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"`
|
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))
|
items := make([]RepportMarketingItemDTO, 0, len(mdps))
|
||||||
|
|
||||||
for _, mdp := range mdps {
|
for _, mdp := range mdps {
|
||||||
hppPerKg := float64(0)
|
hppPerKg := hppByDelivery[mdp.Id]
|
||||||
category := ""
|
category := categoryByDelivery[mdp.Id]
|
||||||
if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil {
|
if category == "" {
|
||||||
if hpp, exists := hppMap[projectFlockKandang.ProjectFlockId]; exists {
|
if projectFlockKandang := mdp.AttributedProjectFlockKandang; projectFlockKandang != nil {
|
||||||
hppPerKg = hpp
|
|
||||||
}
|
|
||||||
category = projectFlockKandang.ProjectFlock.Category
|
category = projectFlockKandang.ProjectFlock.Category
|
||||||
|
} else if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil {
|
||||||
|
category = projectFlockKandang.ProjectFlock.Category
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
soDate := time.Time{}
|
soDate := time.Time{}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
"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"
|
approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
|
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
|
||||||
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
|
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)
|
deliveryIDs := make([]uint, 0, len(deliveryProducts))
|
||||||
hppMap := make(map[uint]float64)
|
for _, delivery := range deliveryProducts {
|
||||||
|
deliveryIDs = append(deliveryIDs, delivery.Id)
|
||||||
|
}
|
||||||
|
|
||||||
for _, dp := range deliveryProducts {
|
attributionRows, err := s.MarketingDeliveryRepo.GetAttributionRowsByDeliveryProductIDs(c.Context(), deliveryIDs)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hppMap[projectFlockID] = 0.0
|
return nil, 0, err
|
||||||
} else if hppCost != nil {
|
|
||||||
hppMap[projectFlockID] = hppCost.Real.HargaKg
|
|
||||||
} else {
|
|
||||||
hppMap[projectFlockID] = 0.0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hppMap[projectFlockID] = 0.0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
|
|
||||||
hppMap[projectFlockID] = 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
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) {
|
func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) {
|
||||||
if err := s.Validate.Struct(params); err != nil {
|
if err := s.Validate.Struct(params); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||||
@@ -135,6 +136,19 @@ var productAllowWithoutSubFlagByFlag = map[FlagType]bool{
|
|||||||
FlagTelur: false,
|
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{
|
var legacyFlagTypeAliases = map[FlagType]FlagType{
|
||||||
FlagDOC: FlagAyam,
|
FlagDOC: FlagAyam,
|
||||||
FlagPullet: FlagAyam,
|
FlagPullet: FlagAyam,
|
||||||
@@ -228,6 +242,52 @@ func ProductFlagAllowWithoutSubFlag(flag FlagType) bool {
|
|||||||
return allow
|
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 {
|
func IsProductMainFlag(flag FlagType) bool {
|
||||||
canonical := canonicalizeFlagType(flag)
|
canonical := canonicalizeFlagType(flag)
|
||||||
for _, f := range productMainFlags {
|
for _, f := range productMainFlags {
|
||||||
|
|||||||
@@ -31,41 +31,62 @@ func MapDepletions(recordingID uint, items []validation.Depletion) []entity.Reco
|
|||||||
return nil
|
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 {
|
for _, item := range items {
|
||||||
if item.ProductWarehouseId == 0 || item.Qty == 0 {
|
if item.ProductWarehouseId == 0 || item.Qty == 0 {
|
||||||
continue
|
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 {
|
if len(aggregate) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]entity.RecordingDepletion, 0, len(aggregate))
|
result := make([]entity.RecordingDepletion, 0, len(aggregate))
|
||||||
for warehouseID, qty := range aggregate {
|
for key, qty := range aggregate {
|
||||||
if qty == 0 {
|
if qty == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
var sourceWarehouseID *uint
|
||||||
|
if key.SourceProductWarehouseID != 0 {
|
||||||
|
sourceWarehouseID = new(uint)
|
||||||
|
*sourceWarehouseID = key.SourceProductWarehouseID
|
||||||
|
}
|
||||||
result = append(result, entity.RecordingDepletion{
|
result = append(result, entity.RecordingDepletion{
|
||||||
RecordingId: recordingID,
|
RecordingId: recordingID,
|
||||||
ProductWarehouseId: warehouseID,
|
ProductWarehouseId: key.ProductWarehouseID,
|
||||||
|
SourceProductWarehouseId: sourceWarehouseID,
|
||||||
Qty: qty,
|
Qty: qty,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
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 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]entity.RecordingEgg, 0, len(items))
|
result := make([]entity.RecordingEgg, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
var sourceProjectFlockKandangID *uint
|
||||||
|
if projectFlockKandangID != 0 {
|
||||||
|
sourceProjectFlockKandangID = new(uint)
|
||||||
|
*sourceProjectFlockKandangID = projectFlockKandangID
|
||||||
|
}
|
||||||
result = append(result, entity.RecordingEgg{
|
result = append(result, entity.RecordingEgg{
|
||||||
RecordingId: recordingID,
|
RecordingId: recordingID,
|
||||||
ProductWarehouseId: item.ProductWarehouseId,
|
ProductWarehouseId: item.ProductWarehouseId,
|
||||||
|
ProjectFlockKandangId: sourceProjectFlockKandangID,
|
||||||
Qty: item.Qty,
|
Qty: item.Qty,
|
||||||
Weight: item.Weight,
|
Weight: item.Weight,
|
||||||
CreatedBy: createdBy,
|
CreatedBy: createdBy,
|
||||||
@@ -79,6 +100,11 @@ type EggTotals struct {
|
|||||||
Weight float64
|
Weight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DepletionRoute struct {
|
||||||
|
ProductWarehouseId uint
|
||||||
|
SourceProductWarehouseId uint
|
||||||
|
}
|
||||||
|
|
||||||
func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 {
|
func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 {
|
||||||
return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) {
|
return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) {
|
||||||
var usage float64
|
var usage float64
|
||||||
@@ -121,6 +147,19 @@ func EggTotalsEqual(a, b map[uint]EggTotals) bool {
|
|||||||
return true
|
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 {
|
func floatNearlyEqual(a, b float64) bool {
|
||||||
return a-b <= 0.000001 && b-a <= 0.000001
|
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
|
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 {
|
func EggTotalsByWarehouse[T any](items []T, get func(T) (uint, int, *float64)) map[uint]EggTotals {
|
||||||
result := make(map[uint]EggTotals)
|
result := make(map[uint]EggTotals)
|
||||||
for _, item := range items {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user