diff --git a/cmd/adjust-quantity-product-warehouse-from-purchase/main.go b/cmd/adjust-quantity-product-warehouse-from-purchase/main.go new file mode 100644 index 00000000..11b7bbb2 --- /dev/null +++ b/cmd/adjust-quantity-product-warehouse-from-purchase/main.go @@ -0,0 +1,297 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "math" + "os" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + levelAllNoFlagProducts = 1 + levelProductName = 2 + levelProductWarehouse = 3 + qtyEpsilon = 1e-6 +) + +type targetRow struct { + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + CurrentQty float64 `gorm:"column:current_qty"` + ComputedQty float64 `gorm:"column:computed_qty"` +} + +func main() { + var ( + level int + productName string + productWarehouseID uint + apply bool + ) + + flag.IntVar( + &level, + "level", + levelAllNoFlagProducts, + "CLI level: 1=all products without flags, 2=specific product name (with flags), 3=specific product warehouse id", + ) + flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)") + flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)") + flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run") + flag.Parse() + + productName = strings.TrimSpace(productName) + if err := validateFlags(level, productName, productWarehouseID); err != nil { + log.Fatalf("invalid flags: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + + targets, err := loadTargets(ctx, db, level, productName, productWarehouseID) + if err != nil { + log.Fatalf("failed to load target product warehouses: %v", err) + } + + fmt.Printf("Mode: %s\n", modeLabel(apply)) + fmt.Printf("Level: %d (%s)\n", level, levelLabel(level)) + if productName != "" { + fmt.Printf("Filter product_name: %s\n", productName) + } + if productWarehouseID > 0 { + fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID) + } + fmt.Printf("Targets found: %d\n\n", len(targets)) + + if len(targets) == 0 { + fmt.Println("No matching product warehouse rows to process") + return + } + + for _, row := range targets { + fmt.Printf( + "PLAN pw=%d product_id=%d product=%q current_qty=%.3f computed_qty=%.3f delta=%.3f\n", + row.ProductWarehouseID, + row.ProductID, + row.ProductName, + row.CurrentQty, + row.ComputedQty, + row.ComputedQty-row.CurrentQty, + ) + } + + if !apply { + fmt.Println() + fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0\n", len(targets)) + return + } + + updated := 0 + skipped := 0 + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, row := range targets { + if nearlyEqual(row.CurrentQty, row.ComputedQty) { + fmt.Printf( + "SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n", + row.ProductWarehouseID, + row.CurrentQty, + row.ComputedQty, + ) + skipped++ + continue + } + + if err := tx.Table("product_warehouses"). + Where("id = ?", row.ProductWarehouseID). + Update("qty", row.ComputedQty).Error; err != nil { + return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err) + } + + fmt.Printf( + "DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n", + row.ProductWarehouseID, + row.ProductID, + row.ProductName, + row.CurrentQty, + row.ComputedQty, + ) + updated++ + } + return nil + }) + if err != nil { + fmt.Println() + fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=1\n", len(targets), updated, skipped) + log.Printf("error: %v", err) + os.Exit(1) + } + + fmt.Println() + fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=0\n", len(targets), updated, skipped) +} + +func validateFlags(level int, productName string, productWarehouseID uint) error { + switch level { + case levelAllNoFlagProducts: + if productName != "" { + return errors.New("--product-name cannot be used on level 1") + } + if productWarehouseID > 0 { + return errors.New("--product-warehouse-id cannot be used on level 1") + } + case levelProductName: + if productName == "" { + return errors.New("--product-name is required on level 2") + } + if productWarehouseID > 0 { + return errors.New("--product-warehouse-id cannot be used on level 2") + } + case levelProductWarehouse: + if productWarehouseID == 0 { + return errors.New("--product-warehouse-id is required on level 3") + } + if productName != "" { + return errors.New("--product-name cannot be used on level 3") + } + default: + return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level) + } + + return nil +} + +func loadTargets( + ctx context.Context, + db *gorm.DB, + level int, + productName string, + productWarehouseID uint, +) ([]targetRow, error) { + switch level { + case levelAllNoFlagProducts: + return loadTargetsLevel1ByProductWithoutFlags(ctx, db) + case levelProductName: + return loadTargetsLevel2ByProductWarehouseWithFlags(ctx, db, productName) + case levelProductWarehouse: + return loadTargetByProductWarehouseID(ctx, db, productWarehouseID) + default: + return nil, fmt.Errorf("unsupported level %d", level) + } +} + +func loadTargetsLevel1ByProductWithoutFlags(ctx context.Context, db *gorm.DB) ([]targetRow, error) { + rows := make([]targetRow, 0) + if err := db.WithContext(ctx). + Table("product_warehouses pw"). + Select(` + pw.id AS product_warehouse_id, + pw.product_id AS product_id, + p.name AS product_name, + COALESCE(pw.qty, 0) AS current_qty, + COALESCE(SUM(pi.total_qty), 0) AS computed_qty + `). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("p.deleted_at IS NULL"). + Where("f.id IS NULL"). + Group("pw.id, pw.product_id, p.name, pw.qty"). + Order("pw.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func loadTargetsLevel2ByProductWarehouseWithFlags( + ctx context.Context, + db *gorm.DB, + productName string, +) ([]targetRow, error) { + rows := make([]targetRow, 0) + if err := db.WithContext(ctx). + Table("product_warehouses pw"). + Select(` + pw.id AS product_warehouse_id, + pw.product_id AS product_id, + p.name AS product_name, + COALESCE(pw.qty, 0) AS current_qty, + COALESCE(SUM(pi.total_qty), 0) AS computed_qty + `). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("p.deleted_at IS NULL"). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_id = p.id + AND f.flagable_type = ? + ) + `, entity.FlagableTypeProduct). + Where("LOWER(p.name) = LOWER(?)", productName). + Group("pw.id, pw.product_id, p.name, pw.qty"). + Order("pw.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func loadTargetByProductWarehouseID(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]targetRow, error) { + rows := make([]targetRow, 0) + if err := db.WithContext(ctx). + Table("product_warehouses pw"). + Select(` + pw.id AS product_warehouse_id, + pw.product_id AS product_id, + p.name AS product_name, + COALESCE(pw.qty, 0) AS current_qty, + COALESCE(SUM(pi.total_qty), 0) AS computed_qty + `). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.id = ?", productWarehouseID). + Group("pw.id, pw.product_id, p.name, pw.qty"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func levelLabel(level int) string { + switch level { + case levelAllNoFlagProducts: + return "all products without flags (source: purchase_items by product_warehouse_id)" + case levelProductName: + return "specific product name with flags (source: purchase_items by product_warehouse_id)" + case levelProductWarehouse: + return "specific product_warehouse_id (source: purchase_items by product_warehouse_id)" + default: + return "unknown" + } +} + +func nearlyEqual(a, b float64) bool { + return math.Abs(a-b) <= qtyEpsilon +} diff --git a/cmd/reflow-quantity-product-warehouse-from-stock-allocation/main.go b/cmd/reflow-quantity-product-warehouse-from-stock-allocation/main.go new file mode 100644 index 00000000..a4259bf1 --- /dev/null +++ b/cmd/reflow-quantity-product-warehouse-from-stock-allocation/main.go @@ -0,0 +1,282 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "math" + "os" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +const qtyEpsilon = 1e-6 + +const ( + levelAll = 1 + levelByProductName = 2 + levelByProductWarehouse = 3 +) + +type reflowRow struct { + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + CurrentQty float64 `gorm:"column:current_qty"` + SumTotalQty float64 `gorm:"column:sum_total_qty"` + SumAllocatedQty float64 `gorm:"column:sum_allocated_qty"` + ComputedQty float64 `gorm:"column:computed_qty"` +} + +func main() { + var ( + apply bool + level int + productName string + productWarehouseID uint + ) + + flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run") + flag.IntVar(&level, "level", levelAll, "CLI level: 1=all product_warehouse scope, 2=product name scope, 3=product_warehouse_id scope") + flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)") + flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)") + flag.Parse() + + productName = strings.TrimSpace(productName) + if err := validateFlags(level, productName, productWarehouseID); err != nil { + log.Fatalf("invalid flags: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + + rows, err := loadReflowRows(ctx, db, level, productName, productWarehouseID) + if err != nil { + log.Fatalf("failed to calculate reflow qty: %v", err) + } + + fmt.Printf("Mode: %s\n", modeLabel(apply)) + fmt.Printf("Level: %d (%s)\n", level, levelLabel(level)) + if productName != "" { + fmt.Printf("Filter product_name: %s\n", productName) + } + if productWarehouseID > 0 { + fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID) + } + fmt.Printf("Targets found: %d\n\n", len(rows)) + + if len(rows) == 0 { + fmt.Println("No product warehouse found from purchase_items scope") + return + } + + negativePlan := 0 + for _, row := range rows { + if row.ComputedQty < 0 { + negativePlan++ + } + + fmt.Printf( + "PLAN pw=%d product_id=%d product=%q current_qty=%.3f total_qty=%.3f allocated_qty=%.3f computed_qty=%.3f delta=%.3f\n", + row.ProductWarehouseID, + row.ProductID, + row.ProductName, + row.CurrentQty, + row.SumTotalQty, + row.SumAllocatedQty, + row.ComputedQty, + row.ComputedQty-row.CurrentQty, + ) + } + + if !apply { + fmt.Println() + fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0 negative_plan=%d\n", len(rows), negativePlan) + return + } + + updated := 0 + skipped := 0 + negativeUpdated := 0 + + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, row := range rows { + if nearlyEqual(row.CurrentQty, row.ComputedQty) { + fmt.Printf( + "SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n", + row.ProductWarehouseID, + row.CurrentQty, + row.ComputedQty, + ) + skipped++ + continue + } + + if err := tx.Table("product_warehouses"). + Where("id = ?", row.ProductWarehouseID). + Update("qty", row.ComputedQty).Error; err != nil { + return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err) + } + + if row.ComputedQty < 0 { + negativeUpdated++ + } + + fmt.Printf( + "DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n", + row.ProductWarehouseID, + row.ProductID, + row.ProductName, + row.CurrentQty, + row.ComputedQty, + ) + updated++ + } + + return nil + }) + if err != nil { + fmt.Println() + fmt.Printf( + "Summary: planned=%d updated=%d skipped=%d failed=1 negative_plan=%d negative_updated=%d\n", + len(rows), + updated, + skipped, + negativePlan, + negativeUpdated, + ) + log.Printf("error: %v", err) + os.Exit(1) + } + + fmt.Println() + fmt.Printf( + "Summary: planned=%d updated=%d skipped=%d failed=0 negative_plan=%d negative_updated=%d\n", + len(rows), + updated, + skipped, + negativePlan, + negativeUpdated, + ) +} + +func validateFlags(level int, productName string, productWarehouseID uint) error { + switch level { + case levelAll: + if productName != "" { + return errors.New("--product-name cannot be used on level 1") + } + if productWarehouseID > 0 { + return errors.New("--product-warehouse-id cannot be used on level 1") + } + case levelByProductName: + if productName == "" { + return errors.New("--product-name is required on level 2") + } + if productWarehouseID > 0 { + return errors.New("--product-warehouse-id cannot be used on level 2") + } + case levelByProductWarehouse: + if productWarehouseID == 0 { + return errors.New("--product-warehouse-id is required on level 3") + } + if productName != "" { + return errors.New("--product-name cannot be used on level 3") + } + default: + return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level) + } + + return nil +} + +func loadReflowRows( + ctx context.Context, + db *gorm.DB, + level int, + productName string, + productWarehouseID uint, +) ([]reflowRow, error) { + allocSub := db.WithContext(ctx). + Table("stock_allocations sa"). + Select(` + sa.stockable_id, + COALESCE(SUM(sa.qty), 0) AS used_qty + `). + Where("sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.deleted_at IS NULL"). + Group("sa.stockable_id") + + calcSub := db.WithContext(ctx). + Table("purchase_items pi"). + Select(` + pi.product_warehouse_id, + COALESCE(SUM(pi.total_qty), 0) AS sum_total_qty, + COALESCE(SUM(COALESCE(alloc.used_qty, 0)), 0) AS sum_allocated_qty, + COALESCE(SUM(COALESCE(pi.total_qty, 0) - COALESCE(alloc.used_qty, 0)), 0) AS computed_qty + `). + Joins("LEFT JOIN (?) alloc ON alloc.stockable_id = pi.id", allocSub). + Where("pi.product_warehouse_id IS NOT NULL"). + Group("pi.product_warehouse_id") + + query := db.WithContext(ctx). + Table("product_warehouses pw"). + Select(` + pw.id AS product_warehouse_id, + pw.product_id AS product_id, + p.name AS product_name, + COALESCE(pw.qty, 0) AS current_qty, + calc.sum_total_qty, + calc.sum_allocated_qty, + calc.computed_qty + `). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN (?) calc ON calc.product_warehouse_id = pw.id", calcSub). + Order("pw.id ASC") + + switch level { + case levelByProductName: + query = query.Where("LOWER(p.name) = LOWER(?)", productName) + case levelByProductWarehouse: + query = query.Where("pw.id = ?", productWarehouseID) + } + + rows := make([]reflowRow, 0) + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func levelLabel(level int) string { + switch level { + case levelAll: + return "all product_warehouse from purchase_items" + case levelByProductName: + return "specific product name" + case levelByProductWarehouse: + return "specific product_warehouse_id" + default: + return "unknown" + } +} + +func nearlyEqual(a, b float64) bool { + return math.Abs(a-b) <= qtyEpsilon +} diff --git a/docs/farm_stock_attribution_design.md b/docs/farm_stock_attribution_design.md new file mode 100644 index 00000000..b1379bfa --- /dev/null +++ b/docs/farm_stock_attribution_design.md @@ -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. diff --git a/docs/qa_farm_stock_test_cases.csv b/docs/qa_farm_stock_test_cases.csv new file mode 100644 index 00000000..3d05f943 --- /dev/null +++ b/docs/qa_farm_stock_test_cases.csv @@ -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 \ No newline at end of file diff --git a/docs/qa_farm_stock_test_cases.xlsx b/docs/qa_farm_stock_test_cases.xlsx new file mode 100644 index 00000000..437e962c Binary files /dev/null and b/docs/qa_farm_stock_test_cases.xlsx differ diff --git a/internal/common/repository/marketing_delivery_attribution.repository.go b/internal/common/repository/marketing_delivery_attribution.repository.go new file mode 100644 index 00000000..fc7dc088 --- /dev/null +++ b/internal/common/repository/marketing_delivery_attribution.repository.go @@ -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) +} diff --git a/internal/common/repository/marketing_delivery_attribution.repository_test.go b/internal/common/repository/marketing_delivery_attribution.repository_test.go new file mode 100644 index 00000000..8fe3da75 --- /dev/null +++ b/internal/common/repository/marketing_delivery_attribution.repository_test.go @@ -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 +} diff --git a/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.down.sql b/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.down.sql new file mode 100644 index 00000000..9f080f9f --- /dev/null +++ b/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.down.sql @@ -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; diff --git a/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql b/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql new file mode 100644 index 00000000..2e5ea18f --- /dev/null +++ b/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql @@ -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; diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go index 7ac3d967..78ca61ab 100644 --- a/internal/entities/marketing_delivery_product.go +++ b/internal/entities/marketing_delivery_product.go @@ -5,20 +5,22 @@ import ( ) type MarketingDeliveryProduct struct { - Id uint `gorm:"primaryKey;autoIncrement"` - MarketingProductId uint `gorm:"uniqueIndex;not null"` - ProductWarehouseId uint `gorm:"not null"` - UnitPrice float64 `gorm:"type:numeric(15,3)"` - TotalWeight float64 `gorm:"type:numeric(15,3)"` - AvgWeight float64 `gorm:"type:numeric(15,3)"` - TotalPrice float64 `gorm:"type:numeric(15,3)"` - DeliveryDate *time.Time `gorm:"type:timestamptz"` - VehicleNumber string `gorm:"type:varchar(50)"` + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingProductId uint `gorm:"uniqueIndex;not null"` + ProductWarehouseId uint `gorm:"not null"` + AttributedProjectFlockKandangId *uint `gorm:"->;column:attributed_project_flock_kandang_id"` + UnitPrice float64 `gorm:"type:numeric(15,3)"` + TotalWeight float64 `gorm:"type:numeric(15,3)"` + AvgWeight float64 `gorm:"type:numeric(15,3)"` + TotalPrice float64 `gorm:"type:numeric(15,3)"` + DeliveryDate *time.Time `gorm:"type:timestamptz"` + VehicleNumber string `gorm:"type:varchar(50)"` // FIFO Fields UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` PendingQty float64 `gorm:"type:numeric(15,3);default:0;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"` } diff --git a/internal/entities/recording_depletion.go b/internal/entities/recording_depletion.go index ae0b6746..ba3b1d25 100644 --- a/internal/entities/recording_depletion.go +++ b/internal/entities/recording_depletion.go @@ -1,14 +1,16 @@ package entities type RecordingDepletion struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` - Qty float64 `gorm:"column:qty;not null"` - UsageQty float64 `gorm:"column:usage_qty"` - PendingQty float64 `gorm:"column:pending_qty"` + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` + SourceProjectFlockKandangId *uint `gorm:"column:source_project_flock_kandang_id"` + Qty float64 `gorm:"column:qty;not null"` + UsageQty float64 `gorm:"column:usage_qty"` + PendingQty float64 `gorm:"column:pending_qty"` - Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` - ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"` } diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index b48c49ca..a2014a17 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -3,18 +3,20 @@ package entities import "time" type RecordingEgg struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - Qty int `gorm:"column:qty;not null"` - TotalQty float64 `gorm:"column:total_qty"` - TotalUsed float64 `gorm:"column:total_used"` - Weight *float64 `gorm:"column:weight"` - CreatedBy uint `gorm:"column:created_by"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` - ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` + Qty int `gorm:"column:qty;not null"` + TotalQty float64 `gorm:"column:total_qty"` + TotalUsed float64 `gorm:"column:total_used"` + Weight *float64 `gorm:"column:weight"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 421a8d3d..a454d53b 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -44,6 +44,7 @@ type PenjualanRealisasiResponseDTO struct { // === Mapper Functions === func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { + projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e) productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags)) for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags { @@ -51,11 +52,11 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { } var category string - if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { - category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category + if projectFlockKandang != nil { + category = projectFlockKandang.ProjectFlock.Category } - ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category) + ageInDay, ageInWeeks := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category) var product *productDTO.ProductRelationDTO if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { @@ -70,8 +71,8 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { } var kandang *kandangDTO.KandangRelationDTO - if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 { - mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang) + if projectFlockKandang != nil && projectFlockKandang.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(projectFlockKandang.Kandang) kandang = &mapped } @@ -102,6 +103,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { } func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO { + projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e) productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags)) for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags { @@ -109,11 +111,11 @@ func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO { } var category string - if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { - category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category + if projectFlockKandang != nil { + category = projectFlockKandang.ProjectFlock.Category } - ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category) + ageInDay, _ := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category) return SalesDTO{ Age: ageInDay, @@ -164,6 +166,13 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua } } +func resolveMarketingDeliveryProjectFlockKandang(e entity.MarketingDeliveryProduct) *entity.ProjectFlockKandang { + if e.AttributedProjectFlockKandang != nil { + return e.AttributedProjectFlockKandang + } + return e.MarketingProduct.ProductWarehouse.ProjectFlockKandang +} + func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { return 0, 0 diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 8c6905f8..b6d90a63 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -25,8 +25,8 @@ type ClosingRepository interface { SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) - FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) - FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakIncoming(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) + FetchSapronakIncomingDetails(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) @@ -90,6 +90,23 @@ type SapronakQueryParams struct { EndDate *time.Time } +func sapronakIncomingPurchaseQueryParts(params SapronakQueryParams) (string, []any) { + if len(params.ProjectFlockKandangIDs) > 0 { + return sapronakIncomingPurchasesScopedSQL(), []any{ + fifo.UsableKeyRecordingStock.String(), + fifo.UsableKeyProjectChickin.String(), + fifo.StockableKeyPurchaseItems.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + params.ProjectFlockKandangIDs, + params.ProjectFlockKandangIDs, + params.WarehouseIDs, + } + } + + return sapronakIncomingPurchasesSQL, []any{params.WarehouseIDs} +} + func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { db := r.DB().WithContext(ctx) @@ -103,8 +120,10 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak if len(params.WarehouseIDs) == 0 { return []SapronakRow{}, 0, nil } - unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) - args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs) + purchasesSQL, purchaseArgs := sapronakIncomingPurchaseQueryParts(params) + unionParts = append(unionParts, purchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) + args = append(args, purchaseArgs...) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) case validation.SapronakTypeOutgoing: if len(params.WarehouseIDs) > 0 { unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL) @@ -193,8 +212,10 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S if len(params.WarehouseIDs) == 0 { return []SapronakSummaryRow{}, nil } - unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) - args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs) + purchasesSQL, purchaseArgs := sapronakIncomingPurchaseQueryParts(params) + unionParts = append(unionParts, purchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) + args = append(args, purchaseArgs...) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) case validation.SapronakTypeOutgoing: if len(params.WarehouseIDs) > 0 { unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL) @@ -298,10 +319,11 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c err = r.DB().WithContext(ctx). Table("recording_stocks rs"). + Joins("JOIN recordings rec ON rec.id = rs.recording_id"). Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("rec.project_flock_kandangs_id IN ?", projectFlockKandangIDs). Where("f.name = ?", "PAKAN"). Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used"). Scan(&usageAgg).Error @@ -340,10 +362,11 @@ func (r *ClosingRepositoryImpl) SumClaimCullingByProjectFlockKandangIDs(ctx cont err := r.DB().WithContext(ctx). Table("recording_depletions rd"). + Joins("JOIN recordings rec ON rec.id = rd.recording_id"). Joins("JOIN product_warehouses pw ON pw.id = rd.product_warehouse_id"). Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("COALESCE(rd.source_project_flock_kandang_id, rec.project_flock_kandangs_id) IN ?", projectFlockKandangIDs). Where("f.name = ?", utils.FlagAyamCulling). Select("COALESCE(SUM(rd.qty), 0) AS total_culling"). Scan(&agg).Error @@ -358,52 +381,14 @@ func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs if len(projectFlockKandangIDs) == 0 { return 0, 0, 0, nil } - - var agg struct { - TotalWeight float64 `gorm:"column:total_weight"` - TotalQty float64 `gorm:"column:total_qty"` - TotalPrice float64 `gorm:"column:total_price"` - } - - err := r.DB().WithContext(ctx). - Table("marketing_products mp"). - Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). - Select("COALESCE(SUM(mp.total_weight), 0) AS total_weight, COALESCE(SUM(mp.qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price"). - Scan(&agg).Error - if err != nil { - return 0, 0, 0, err - } - - return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil + return r.sumMarketingAttributedByProjectFlockKandangIDs(ctx, projectFlockKandangIDs, nil) } func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) { if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { return 0, 0, 0, nil } - - var agg struct { - TotalWeight float64 `gorm:"column:total_weight"` - TotalQty float64 `gorm:"column:total_qty"` - TotalPrice float64 `gorm:"column:total_price"` - } - - err := r.DB().WithContext(ctx). - Table("marketing_products mp"). - Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). - Joins("JOIN products prod ON prod.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). - Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). - Where("f.name IN ?", flagNames). - Select("COALESCE(SUM(mdp.total_weight), 0) AS total_weight, COALESCE(SUM(mdp.usage_qty), 0) AS total_qty, COALESCE(SUM(mdp.total_price), 0) AS total_price"). - Scan(&agg).Error - if err != nil { - return 0, 0, 0, err - } - - return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil + return r.sumMarketingAttributedByProjectFlockKandangIDs(ctx, projectFlockKandangIDs, flagNames) } func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) { @@ -417,10 +402,11 @@ func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFla err := r.DB().WithContext(ctx). Table("recording_eggs re"). + Joins("JOIN recordings rec ON rec.id = re.recording_id"). Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id"). Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("COALESCE(re.project_flock_kandang_id, rec.project_flock_kandangs_id) IN ?", projectFlockKandangIDs). Where("f.name IN ?", flagNames). Select("COALESCE(SUM(re.qty), 0) AS total_qty"). Scan(&agg).Error @@ -817,6 +803,52 @@ type SapronakDetailRow struct { func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) } +func (r *ClosingRepositoryImpl) sumMarketingAttributedByProjectFlockKandangIDs( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, +) (float64, float64, float64, error) { + var agg struct { + TotalWeight float64 `gorm:"column:total_weight"` + TotalQty float64 `gorm:"column:total_qty"` + TotalPrice float64 `gorm:"column:total_price"` + } + + query := r.withCtx(ctx). + Table("(?) AS mda", repository.MarketingDeliveryAttributionRowsQuery(r.withCtx(ctx))). + Joins("JOIN marketing_delivery_products mdp ON mdp.id = mda.marketing_delivery_product_id"). + Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN products prod ON prod.id = pw.product_id"). + Where("mda.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("mdp.delivery_date IS NOT NULL") + + if len(flagNames) > 0 { + query = query. + Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", flagNames) + } + + err := query. + Select(` + COALESCE(SUM(CASE + WHEN COALESCE(mdp.usage_qty, 0) > 0 THEN mdp.total_weight * (mda.allocated_qty / mdp.usage_qty) + ELSE 0 + END), 0) AS total_weight, + COALESCE(SUM(mda.allocated_qty), 0) AS total_qty, + COALESCE(SUM(CASE + WHEN COALESCE(mdp.usage_qty, 0) > 0 THEN mdp.total_price * (mda.allocated_qty / mdp.usage_qty) + ELSE 0 + END), 0) AS total_price + `). + Scan(&agg).Error + if err != nil { + return 0, 0, 0, err + } + + return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil +} + func applyDateRange(db *gorm.DB, column string, start, end *time.Time) *gorm.DB { if start != nil { db = db.Where(column+"::date >= ?", start) @@ -844,6 +876,140 @@ func sapronakFlags(flags ...utils.FlagType) []string { return out } +func sapronakLegacyFlagByProductCategoryCase(categoryCodeExpr string) string { + return fmt.Sprintf( + `CASE + WHEN UPPER(%s) = 'DOC' THEN '%s' + WHEN UPPER(%s) = 'PLT' THEN '%s' + WHEN UPPER(%s) IN ('RAW', 'PST', 'STR', 'FSR') THEN '%s' + WHEN UPPER(%s) IN ('OBT', 'VTM', 'KMA') THEN '%s' + ELSE NULL + END`, + categoryCodeExpr, utils.FlagDOC, + categoryCodeExpr, utils.FlagPullet, + categoryCodeExpr, utils.FlagPakan, + categoryCodeExpr, utils.FlagOVK, + ) +} + +func sapronakIncomingPurchasesScopedSQL() string { + return ` +WITH scoped_farm_allocations AS ( + SELECT + sa.stockable_id AS purchase_item_id, + COALESCE(SUM(sa.qty), 0) AS allocated_qty + FROM stock_allocations sa + LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ? + LEFT JOIN recordings rec ON rec.id = rs.recording_id AND rec.deleted_at IS NULL + LEFT JOIN project_chickins pc ON pc.id = sa.usable_id AND sa.usable_type = ? + WHERE sa.stockable_type = ? + AND sa.status = ? + AND sa.allocation_purpose = ? + AND COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) IN ? + GROUP BY sa.stockable_id +) +SELECT + CAST(pi.id AS BIGINT) AS id, + COALESCE(pi.received_date, '1970-01-01') AS sort_date, + COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, + COALESCE(p.po_number, '') AS reference_number, + 'Pembelian' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + '-' AS source_warehouse, + w.name AS destination_warehouse, + '' AS destination, + pi.total_qty AS quantity, + u.id AS unit_id, + u.name AS unit, + COALESCE(p.notes, '') AS notes +FROM purchase_items pi +JOIN purchases p ON p.id = pi.purchase_id +JOIN products prod ON prod.id = pi.product_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pi.warehouse_id +WHERE w.kandang_id IS NOT NULL + AND ( + pi.project_flock_kandang_id IN ? + OR (pi.project_flock_kandang_id IS NULL AND pi.warehouse_id IN ?) + ) +UNION ALL +SELECT + CAST(pi.id AS BIGINT) AS id, + COALESCE(pi.received_date, '1970-01-01') AS sort_date, + COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, + COALESCE(p.po_number, '') AS reference_number, + 'Pembelian' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + '-' AS source_warehouse, + w.name AS destination_warehouse, + '' AS destination, + sfa.allocated_qty AS quantity, + u.id AS unit_id, + u.name AS unit, + COALESCE(p.notes, '') AS notes +FROM purchase_items pi +JOIN purchases p ON p.id = pi.purchase_id +JOIN products prod ON prod.id = pi.product_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pi.warehouse_id +JOIN scoped_farm_allocations sfa ON sfa.purchase_item_id = pi.id +WHERE w.kandang_id IS NULL + AND COALESCE(sfa.allocated_qty, 0) > 0 +` +} + var ( sapronakFlagsAll = sapronakFlags(utils.FlagDOC, utils.FlagPakan, utils.FlagOVK, utils.FlagPullet) sapronakFlagsUsage = sapronakFlags(utils.FlagPakan, utils.FlagOVK) @@ -851,18 +1017,44 @@ var ( ) func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB { - subquery := r.DB(). + actualFlags := r.DB(). Table("flags"). - Select("DISTINCT ON (flagable_id) flagable_id, name"). + Select(` + flagable_id, + MIN(CASE + WHEN UPPER(name) = 'DOC' THEN 1 + WHEN UPPER(name) = 'PULLET' THEN 2 + WHEN UPPER(name) = 'PAKAN' THEN 3 + WHEN UPPER(name) = 'OVK' THEN 4 + ELSE 5 + END) AS priority + `). Where("flagable_type = ?", entity.FlagableTypeProduct). - Where("name IN ?", sapronakFlagsAll). - Order(fmt.Sprintf( - "flagable_id, CASE WHEN name = '%s' THEN 1 WHEN name = '%s' THEN 2 WHEN name = '%s' THEN 3 WHEN name = '%s' THEN 4 ELSE 5 END", + Where("UPPER(name) IN ?", sapronakFlagsAll). + Group("flagable_id") + + legacyFlagExpr := sapronakLegacyFlagByProductCategoryCase("pc.code") + subquery := r.DB(). + Table("products AS sapronak_products"). + Select(fmt.Sprintf(` + sapronak_products.id AS flagable_id, + CASE + WHEN actual_flags.priority = 1 THEN '%s' + WHEN actual_flags.priority = 2 THEN '%s' + WHEN actual_flags.priority = 3 THEN '%s' + WHEN actual_flags.priority = 4 THEN '%s' + ELSE %s + END AS name + `, utils.FlagDOC, utils.FlagPullet, utils.FlagPakan, utils.FlagOVK, - )) + legacyFlagExpr, + )). + Joins("LEFT JOIN (?) AS actual_flags ON actual_flags.flagable_id = sapronak_products.id", actualFlags). + Joins("LEFT JOIN product_categories pc ON pc.id = sapronak_products.product_category_id"). + Where("actual_flags.priority IS NOT NULL OR " + legacyFlagExpr + " IS NOT NULL") return db.Joins("JOIN (?) f ON f.flagable_id = "+productAlias+".id", subquery) } @@ -1121,22 +1313,111 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C return scanAndGroupDetails(query) } -func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint, start, end *time.Time) *gorm.DB { +func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) *gorm.DB { db := r.withCtx(ctx). Table("purchase_items AS pi"). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). Joins("JOIN products p ON p.id = pi.product_id"). Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). - Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Where("pi.received_date IS NOT NULL") + if projectFlockKandangID > 0 { + db = db.Where( + "w.kandang_id = ? AND (pi.project_flock_kandang_id = ? OR pi.project_flock_kandang_id IS NULL)", + kandangID, + projectFlockKandangID, + ) + } else { + db = db.Where("w.kandang_id = ?", kandangID) + } + db = applyDateRange(db, "pi.received_date", start, end) + return r.joinSapronakProductFlag(db, "p") +} + +func (r *ClosingRepositoryImpl) incomingFarmPurchaseAllocationBase(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) *gorm.DB { + db := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Joins("JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). + Joins("JOIN products p ON p.id = pi.product_id"). + Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). + Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()). + Joins("LEFT JOIN recordings rec ON rec.id = rs.recording_id AND rec.deleted_at IS NULL"). + Joins("LEFT JOIN project_chickins pc ON pc.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("w.kandang_id IS NULL"). + Where("COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Where("pi.received_date IS NOT NULL") db = applyDateRange(db, "pi.received_date", start, end) return r.joinSapronakProductFlag(db, "p") } -func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) { +func mergeSapronakIncomingRows(primary []SapronakIncomingRow, extra []SapronakIncomingRow) []SapronakIncomingRow { + if len(extra) == 0 { + return primary + } + + type key struct { + productID uint + flag string + } + + merged := make(map[key]*SapronakIncomingRow, len(primary)+len(extra)) + order := make([]key, 0, len(primary)+len(extra)) + + add := func(rows []SapronakIncomingRow) { + for _, row := range rows { + k := key{productID: row.ProductID, flag: row.Flag} + if existing, ok := merged[k]; ok { + existing.Qty += row.Qty + existing.Value += row.Value + if existing.ProductName == "" { + existing.ProductName = row.ProductName + } + if existing.DefaultPrice == 0 { + existing.DefaultPrice = row.DefaultPrice + } + continue + } + + copyRow := row + merged[k] = ©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) - db := r.incomingPurchaseBase(ctx, kandangID, start, end).Select(` + db := r.incomingPurchaseBase(ctx, projectFlockKandangID, kandangID, start, end).Select(` pi.product_id AS product_id, p.name AS product_name, f.name AS flag, @@ -1147,22 +1428,68 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kanda if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { return nil, err } - return rows, nil + + if projectFlockKandangID == 0 { + return rows, nil + } + + farmRows := make([]SapronakIncomingRow, 0) + farmDB := r.incomingFarmPurchaseAllocationBase(ctx, projectFlockKandangID, start, end).Select(` + pi.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(SUM(sa.qty), 0) AS qty, + COALESCE(SUM(sa.qty * pi.price), 0) AS value, + COALESCE(p.product_price, 0) AS default_price + `) + if err := farmDB.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&farmRows).Error; err != nil { + return nil, err + } + + return mergeSapronakIncomingRows(rows, farmRows), nil } -func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { - return scanAndGroupDetails( - r.incomingPurchaseBase(ctx, kandangID, start, end).Select(` +func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { + rows, err := scanAndGroupDetails( + r.incomingPurchaseBase(ctx, projectFlockKandangID, kandangID, start, end).Select(` pi.product_id AS product_id, p.name AS product_name, f.name AS flag, pi.received_date AS date, COALESCE(po.po_number, '') AS reference, COALESCE(pi.total_qty,0) AS qty_in, + 0 AS qty_out, + COALESCE(pi.price,0) AS price + `), + ) + if err != nil { + return nil, err + } + + if projectFlockKandangID == 0 { + return rows, nil + } + + farmRows, err := scanAndGroupDetails( + r.incomingFarmPurchaseAllocationBase(ctx, projectFlockKandangID, start, end).Select(` + pi.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + pi.received_date AS date, + COALESCE(po.po_number, '') AS reference, + COALESCE(SUM(sa.qty),0) AS qty_in, 0 AS qty_out, COALESCE(pi.price,0) AS price + `).Group(` + pi.id, pi.product_id, p.name, f.name, + pi.received_date, po.po_number, pi.price `), ) + if err != nil { + return nil, err + } + + return mergeSapronakDetailMaps(rows, farmRows), nil } type stockLogSapronakRow struct { @@ -1453,6 +1780,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand } func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { + attributedProjectFlockKandangExpr := ` + COALESCE( + pc.project_flock_kandang_id, + pi.project_flock_kandang_id, + source_pw.project_flock_kandang_id, + ltt.target_project_flock_kandang_id, + pw.project_flock_kandang_id + ) + ` + query := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` @@ -1470,9 +1807,15 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Joins("JOIN marketings m ON m.id = mp.marketing_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("LEFT JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where(attributedProjectFlockKandangExpr+" = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") @@ -1548,6 +1891,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C END `, pfpType) + attributedProjectFlockKandangExpr := ` + COALESCE( + pc.project_flock_kandang_id, + pi.project_flock_kandang_id, + source_pw.project_flock_kandang_id, + ltt.target_project_flock_kandang_id, + pw_sales.project_flock_kandang_id + ) + ` + query := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(fmt.Sprintf(` @@ -1600,6 +1953,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id"). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id"). @@ -1619,7 +1973,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where(attributedProjectFlockKandangExpr+" = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group(` p_resolve.id, p_resolve.name, f.name, diff --git a/internal/modules/closings/repositories/closing.repository_test.go b/internal/modules/closings/repositories/closing.repository_test.go new file mode 100644 index 00000000..362dbef2 --- /dev/null +++ b/internal/modules/closings/repositories/closing.repository_test.go @@ -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 +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index cd8ea5ac..360a39f9 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -383,7 +383,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa var projectFlockKandangIDs []uint if params.KandangID != nil && *params.KandangID > 0 { projectFlockKandangIDs = []uint{*params.KandangID} - } else if params.Type == validation.SapronakTypeOutgoing { + } else { projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) @@ -474,7 +474,7 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u var projectFlockKandangIDs []uint if params.KandangID != nil && *params.KandangID > 0 { projectFlockKandangIDs = []uint{*params.KandangID} - } else if params.Type == validation.SapronakTypeOutgoing { + } else { projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) @@ -1156,7 +1156,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint chickenDepletion = 0 } -chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age) + chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age) if fcrActFromRecording != nil { chickenPerformance.FcrAct = *fcrActFromRecording } diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 460b139a..f548820a 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -382,11 +382,11 @@ func buildSapronakDetails( func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { // Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any). startDate, endDate := sapronakPeriodRange(pfk) - incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, startDate, endDate) + incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.Id, pfk.KandangId, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId, startDate, endDate) + incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.Id, pfk.KandangId, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index f6753848..94cbd371 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -5,6 +5,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" + uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -14,6 +15,7 @@ type ProductRelationDTO struct { Id uint `json:"id"` Name string `json:"name"` SKU string `json:"sku"` + Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` } @@ -89,11 +91,17 @@ func ToProductRelationDTO(e *entity.Product) *ProductRelationDTO { mapped := productCategoryDTO.ToProductCategoryRelationDTO(e.ProductCategory) category = &mapped } + var uom *uomDTO.UomRelationDTO + if e.Uom.Id != 0 { + mapped := uomDTO.ToUomRelationDTO(e.Uom) + uom = &mapped + } return &ProductRelationDTO{ Id: e.Id, Name: e.Name, SKU: sku, + Uom: uom, ProductCategory: category, } } diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index 5737e9f0..95ac6b82 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -3,6 +3,7 @@ package controller import ( "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services" @@ -27,10 +28,13 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), + LocationId: uint(c.QueryInt("location_id", 0)), Flags: c.Query("flags", ""), KandangId: uint(c.QueryInt("kandang_id", 0)), + AvailableOnly: parseBoolQuery(c.Query("available_only", "")), TransferContext: c.Query(utils.TransferContextKey, ""), StockMode: c.Query("stock_mode", ""), Type: c.Query("type", ""), @@ -60,6 +64,15 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { }) } +func parseBoolQuery(raw string) bool { + switch strings.TrimSpace(strings.ToLower(raw)) { + case "1", "true", "yes", "y": + return true + default: + return false + } +} + func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error { param := c.Params("id") diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller_test.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller_test.go new file mode 100644 index 00000000..8905c47c --- /dev/null +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller_test.go @@ -0,0 +1,70 @@ +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&search=tektrol&available_only=true", 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) + } + if stub.lastQuery.Search != "tektrol" { + t.Fatalf("expected search tektrol, got %s", stub.lastQuery.Search) + } + if !stub.lastQuery.AvailableOnly { + t.Fatalf("expected available_only true") + } +} + +func TestStubImplementsServiceContract(t *testing.T) { + validate := validator.New() + if validate == nil { + t.Fatal(errors.New("validator should not be nil")) + } +} diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index e49fc421..2659533d 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -7,6 +7,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -84,31 +85,28 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho } func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) { - var productWarehouse entity.ProductWarehouse - - err := r.DB().WithContext(ctx). - Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId). - Order("id DESC"). - Preload("ProjectFlockKandang"). - First(&productWarehouse).Error - - if err == nil { - - if productWarehouse.ProjectFlockKandang.ClosedAt == nil { - return &productWarehouse, nil - } - - } - - err = r.DB().WithContext(ctx). - Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId). - First(&productWarehouse).Error - + warehouseIsKandang, err := r.isKandangWarehouse(ctx, warehouseId) if err != nil { return nil, err } - return &productWarehouse, nil + if warehouseIsKandang { + if productWarehouse, err := r.findOpenKandangOwnedWarehouse(ctx, productId, warehouseId); err == nil { + return productWarehouse, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + return r.findSharedWarehouse(ctx, productId, warehouseId) + } + + if productWarehouse, err := r.findSharedWarehouse(ctx, productId, warehouseId); err == nil { + return productWarehouse, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + return r.findOpenKandangOwnedWarehouse(ctx, productId, warehouseId) } func (r *ProductWarehouseRepositoryImpl) FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) { @@ -167,10 +165,42 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s return db } - return db. + fallbackCategoryCodes := utils.LegacyProductCategoryCodesForFlags(flags) + + db = db. Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id"). - Joins("JOIN flags f_flag ON f_flag.flagable_id = p_flag.id AND f_flag.flagable_type = ?", "products"). - Where("f_flag.name IN ?", flags). + Joins("LEFT JOIN product_categories pc_flag ON pc_flag.id = p_flag.product_category_id") + + actualFlagFilter := ` + EXISTS ( + SELECT 1 + FROM flags f_flag + WHERE f_flag.flagable_id = p_flag.id + AND f_flag.flagable_type = ? + AND f_flag.name IN ? + ) + ` + + if len(fallbackCategoryCodes) == 0 { + return db.Where(actualFlagFilter, entity.FlagableTypeProduct, flags).Distinct() + } + + return db. + Where( + `(`+actualFlagFilter+`) OR ( + NOT EXISTS ( + SELECT 1 + FROM flags f_any + WHERE f_any.flagable_id = p_flag.id + AND f_any.flagable_type = ? + ) + AND pc_flag.code IN ? + )`, + entity.FlagableTypeProduct, + flags, + entity.FlagableTypeProduct, + fallbackCategoryCodes, + ). Distinct() } @@ -266,18 +296,8 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( projectFlockKandangID *uint, createdBy uint, ) (uint, error) { - record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) + record, err := r.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID) if err == nil { - // Backfill project_flock_kandang_id when it's missing and caller provides one. - if projectFlockKandangID != nil && (record.ProjectFlockKandangId == nil || *record.ProjectFlockKandangId == 0) { - if err := r.DB().WithContext(ctx). - Model(&entity.ProductWarehouse{}). - Where("id = ?", record.Id). - Update("project_flock_kandang_id", *projectFlockKandangID).Error; err != nil { - return 0, err - } - record.ProjectFlockKandangId = projectFlockKandangID - } return record.Id, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -301,6 +321,45 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( return entity.Id, nil } +func (r *ProductWarehouseRepositoryImpl) isKandangWarehouse(ctx context.Context, warehouseID uint) (bool, error) { + var kandangID *uint + if err := r.DB().WithContext(ctx). + Table("warehouses"). + Select("kandang_id"). + Where("id = ?", warehouseID). + Scan(&kandangID).Error; err != nil { + return false, err + } + return kandangID != nil && *kandangID != 0, nil +} + +func (r *ProductWarehouseRepositoryImpl) findOpenKandangOwnedWarehouse(ctx context.Context, productID uint, warehouseID uint) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productID, warehouseID). + Order("id DESC"). + Preload("ProjectFlockKandang"). + First(&productWarehouse).Error + if err != nil { + return nil, err + } + if productWarehouse.ProjectFlockKandang != nil && productWarehouse.ProjectFlockKandang.ClosedAt == nil { + return &productWarehouse, nil + } + return nil, gorm.ErrRecordNotFound +} + +func (r *ProductWarehouseRepositoryImpl) findSharedWarehouse(ctx context.Context, productID uint, warehouseID uint) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + if err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productID, warehouseID). + Preload("ProjectFlockKandang"). + First(&productWarehouse).Error; err != nil { + return nil, err + } + return &productWarehouse, nil +} + func (r *ProductWarehouseRepositoryImpl) GetByProductWarehouseAndProjectFlockKandang( ctx context.Context, productId uint, diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go new file mode 100644 index 00000000..3f810e53 --- /dev/null +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go @@ -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 +} diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 28d1f9c3..61c4536a 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -53,6 +53,31 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Chickins") } +func applyWarehouseSelectionFilter(db *gorm.DB, kandangID, locationID uint) *gorm.DB { + switch { + case kandangID != 0 && locationID != 0: + return db.Where( + "w_scope.location_id = ? AND (w_scope.type = ? OR w_scope.kandang_id = ?)", + locationID, + "LOKASI", + kandangID, + ) + case kandangID != 0: + return db.Where("w_scope.kandang_id = ?", kandangID) + case locationID != 0: + return db.Where("w_scope.location_id = ?", locationID) + default: + return db + } +} + +func applyAvailableOnlyFilter(db *gorm.DB, availableOnly bool) *gorm.DB { + if !availableOnly { + return db + } + return db.Where("COALESCE(product_warehouses.qty, 0) > 0") +} + func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -133,15 +158,31 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("product_id = ?", params.ProductId) } - if params.KandangId != 0 { - db = db.Joins("JOIN warehouses ON product_warehouses.warehouse_id = warehouses.id"). - Where("warehouses.kandang_id = ?", params.KandangId) - } + db = applyAvailableOnlyFilter(db, params.AvailableOnly) + + db = applyWarehouseSelectionFilter(db, params.KandangId, params.LocationId) if params.WarehouseId != 0 { db = db.Where("warehouse_id = ?", params.WarehouseId) } + if strings.TrimSpace(params.Search) != "" { + searchPattern := "%" + strings.TrimSpace(params.Search) + "%" + db = db.Where( + `( + EXISTS ( + SELECT 1 + FROM products p_search + WHERE p_search.id = product_warehouses.product_id + AND p_search.name ILIKE ? + ) + OR w_scope.name ILIKE ? + )`, + searchPattern, + searchPattern, + ) + } + if len(marketingTypes) > 0 { flagSet := make(map[string]struct{}) for _, t := range marketingTypes { diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service_test.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service_test.go new file mode 100644 index 00000000..b71b5f2f --- /dev/null +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service_test.go @@ -0,0 +1,126 @@ +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 TestApplyAvailableOnlyFilterRemovesZeroQtyRows(t *testing.T) { + db := setupProductWarehouseServiceTestDB(t) + + var ids []uint + err := applyAvailableOnlyFilter(baseProductWarehouseSelectionQuery(db), true). + 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, 4}) +} + +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, + qty NUMERIC 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, qty) VALUES + (1, 1, 10), + (2, 2, 20), + (3, 3, 0), + (4, 4, 15)`, + } + + 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) + } + } +} diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 348fd96d..164c96bc 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -15,10 +15,13 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty"` ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + LocationId uint `query:"location_id" validate:"omitempty,number,min=1"` Flags string `query:"flags" validate:"omitempty"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` + AvailableOnly bool `query:"available_only"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` StockMode string `query:"stock_mode" validate:"omitempty,oneof=exclude_chickin"` Type string `query:"type" validate:"omitempty"` diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 17394b80..f0216570 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -2,9 +2,10 @@ package repository import ( "context" + "sort" "strings" - "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -12,7 +13,7 @@ import ( ) type MarketingDeliveryProductRepository interface { - repository.BaseRepository[entity.MarketingDeliveryProduct] + commonRepo.BaseRepository[entity.MarketingDeliveryProduct] GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) @@ -23,26 +24,27 @@ type MarketingDeliveryProductRepository interface { GetUsageQty(ctx context.Context, id uint) (float64, error) ResetFifoFields(ctx context.Context, id uint) error GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) + GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error) } type MarketingDeliveryProductRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct] + *commonRepo.BaseRepositoryImpl[entity.MarketingDeliveryProduct] } func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { return &MarketingDeliveryProductRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), + BaseRepositoryImpl: commonRepo.NewBaseRepository[entity.MarketingDeliveryProduct](db), } } func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) { var deliveryProducts []entity.MarketingDeliveryProduct + attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx)) + db := r.DB().WithContext(ctx). - Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). - Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). - Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). - Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = marketing_delivery_products.id", attributionQuery). + Where("mda.project_flock_id = ?", projectFlockID). Distinct("marketing_delivery_products.*") if callback != nil { @@ -57,139 +59,50 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo } func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { - var deliveryProducts []entity.MarketingDeliveryProduct - - db := r.DB().WithContext(ctx). - Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). - Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). - Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). - Where("marketing_delivery_products.delivery_date IS NOT NULL"). - Distinct("marketing_delivery_products.*") - - if projectFlockKandangID != nil { - db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID) - } - - db = db. - Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.Product"). - Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). - Preload("MarketingProduct.ProductWarehouse.Product.Uom"). - Preload("MarketingProduct.ProductWarehouse.Product.Flags"). - Preload("MarketingProduct.ProductWarehouse.Warehouse"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). - Preload("MarketingProduct.Marketing"). - Preload("MarketingProduct.Marketing.Customer"). - Order("marketing_delivery_products.delivery_date DESC") - - if err := db.Find(&deliveryProducts).Error; err != nil { + attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, nil) + if err != nil { return nil, err } - - return deliveryProducts, nil + return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID) } func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { - var deliveryProducts []entity.MarketingDeliveryProduct - - db := r.DB().WithContext(ctx). - Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). - Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). - Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). - Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). - Where("flags.name IN (?)", []string{ - string(utils.FlagAyamAfkir), - string(utils.FlagAyamCulling), - string(utils.FlagPullet), - string(utils.FlagLayer), - }). - Where("marketing_delivery_products.delivery_date IS NOT NULL"). - Distinct("marketing_delivery_products.*") - - if projectFlockKandangID != nil { - db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID) - } - - db = db. - Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.Product"). - Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). - Preload("MarketingProduct.ProductWarehouse.Product.Flags"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). - Order("marketing_delivery_products.delivery_date DESC") - - if err := db.Find(&deliveryProducts).Error; err != nil { + attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, []string{ + string(utils.FlagAyamAfkir), + string(utils.FlagAyamCulling), + string(utils.FlagPullet), + string(utils.FlagLayer), + }) + if err != nil { return nil, err } - - return deliveryProducts, nil + return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID) } func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) { - var deliveryProducts []entity.MarketingDeliveryProduct - - db := r.DB().WithContext(ctx). - Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). - Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). - Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). - Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). - Where("marketing_delivery_products.delivery_date IS NOT NULL"). - Distinct("marketing_delivery_products.*") - - if projectFlockKandangID != nil { - db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID) + flagNames := []string{ + string(utils.FlagDOC), + string(utils.FlagPullet), + string(utils.FlagLayer), + string(utils.FlagAyamAfkir), + string(utils.FlagAyamCulling), + string(utils.FlagAyamMati), } - if category == string(utils.ProjectFlockCategoryLaying) { - db = db.Where("flags.name IN (?)", []string{ + flagNames = []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.FlagPullet), - string(utils.FlagLayer), - string(utils.FlagAyamAfkir), - string(utils.FlagAyamCulling), - string(utils.FlagAyamMati), - }) + } } - db = db. - Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.Product"). - Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). - Preload("MarketingProduct.ProductWarehouse.Product.Uom"). - Preload("MarketingProduct.ProductWarehouse.Product.Flags"). - Preload("MarketingProduct.ProductWarehouse.Warehouse"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). - Preload("MarketingProduct.Marketing"). - Preload("MarketingProduct.Marketing.Customer"). - Order("marketing_delivery_products.delivery_date DESC") - - if err := db.Find(&deliveryProducts).Error; err != nil { + attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, flagNames) + if err != nil { return nil, err } - - return deliveryProducts, nil + return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID) } func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { @@ -219,12 +132,199 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con return &deliveryProduct, nil } +func (r *MarketingDeliveryProductRepositoryImpl) GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error) { + if len(deliveryProductIDs) == 0 { + return []commonRepo.MarketingDeliveryAttributionRow{}, nil + } + + var rows []commonRepo.MarketingDeliveryAttributionRow + query := r.DB().WithContext(ctx). + Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))). + Where("mda.marketing_delivery_product_id IN ?", deliveryProductIDs). + Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) getClosingAttributionRows( + ctx context.Context, + projectFlockID uint, + projectFlockKandangID *uint, + flagNames []string, +) ([]commonRepo.MarketingDeliveryAttributionRow, error) { + var rows []commonRepo.MarketingDeliveryAttributionRow + + attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx)) + query := r.DB().WithContext(ctx). + Table("(?) AS mda", attributionQuery). + Joins("JOIN marketing_delivery_products mdp ON mdp.id = mda.marketing_delivery_product_id"). + Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN products prod ON prod.id = pw.product_id"). + Where("mda.project_flock_id = ?", projectFlockID). + Where("mdp.delivery_date IS NOT NULL") + + if projectFlockKandangID != nil { + query = query.Where("mda.project_flock_kandang_id = ?", *projectFlockKandangID) + } + if len(flagNames) > 0 { + query = query. + Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", flagNames) + } + + query = query. + Select(` + mda.marketing_delivery_product_id, + mda.project_flock_kandang_id, + mda.project_flock_id, + mda.project_flock_category, + SUM(mda.allocated_qty) AS allocated_qty + `). + Group(` + mda.marketing_delivery_product_id, + mda.project_flock_kandang_id, + mda.project_flock_id, + mda.project_flock_category + `). + Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) fetchClosingDeliveryProducts( + ctx context.Context, + attributionRows []commonRepo.MarketingDeliveryAttributionRow, + projectFlockKandangID *uint, +) ([]entity.MarketingDeliveryProduct, error) { + deliveryIDs := orderedDeliveryProductIDs(attributionRows) + if len(deliveryIDs) == 0 { + return []entity.MarketingDeliveryProduct{}, nil + } + + query := r.closingDeliveryProductsQuery(ctx). + Where("marketing_delivery_products.id IN ?", deliveryIDs). + Order("marketing_delivery_products.delivery_date DESC") + + if projectFlockKandangID == nil { + query = query.Joins( + "LEFT JOIN (?) AS mda_single ON mda_single.marketing_delivery_product_id = marketing_delivery_products.id", + commonRepo.MarketingDeliverySingleAttributionQuery(r.DB().WithContext(ctx)), + ).Select("marketing_delivery_products.*, mda_single.attributed_project_flock_kandang_id") + } + + var deliveryProducts []entity.MarketingDeliveryProduct + if err := query.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + if projectFlockKandangID == nil { + return deliveryProducts, nil + } + + return scaleDeliveryProductsByAttribution(deliveryProducts, attributionRows, *projectFlockKandangID), nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) closingDeliveryProductsQuery(ctx context.Context) *gorm.DB { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product"). + Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). + Preload("MarketingProduct.ProductWarehouse.Product.Uom"). + Preload("MarketingProduct.ProductWarehouse.Product.Flags"). + Preload("MarketingProduct.ProductWarehouse.Warehouse"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). + Preload("MarketingProduct.Marketing"). + Preload("MarketingProduct.Marketing.Customer"). + Preload("AttributedProjectFlockKandang"). + Preload("AttributedProjectFlockKandang.ProjectFlock"). + Preload("AttributedProjectFlockKandang.Kandang"). + Preload("AttributedProjectFlockKandang.Chickins") +} + +func orderedDeliveryProductIDs(rows []commonRepo.MarketingDeliveryAttributionRow) []uint { + seen := make(map[uint]struct{}, len(rows)) + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + if row.MarketingDeliveryProductID == 0 { + continue + } + if _, ok := seen[row.MarketingDeliveryProductID]; ok { + continue + } + seen[row.MarketingDeliveryProductID] = struct{}{} + ids = append(ids, row.MarketingDeliveryProductID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func scaleDeliveryProductsByAttribution( + deliveryProducts []entity.MarketingDeliveryProduct, + rows []commonRepo.MarketingDeliveryAttributionRow, + projectFlockKandangID uint, +) []entity.MarketingDeliveryProduct { + if len(deliveryProducts) == 0 || projectFlockKandangID == 0 { + return deliveryProducts + } + + totalByDelivery := make(map[uint]float64, len(rows)) + selectedByDelivery := make(map[uint]float64, len(rows)) + for _, row := range rows { + totalByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty + if row.ProjectFlockKandangID == projectFlockKandangID { + selectedByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty + } + } + + filtered := make([]entity.MarketingDeliveryProduct, 0, len(deliveryProducts)) + for _, delivery := range deliveryProducts { + selectedQty := selectedByDelivery[delivery.Id] + totalQty := totalByDelivery[delivery.Id] + if selectedQty <= 0 { + continue + } + + share := 1.0 + if totalQty > 0 { + share = selectedQty / totalQty + } + + cloned := delivery + cloned.AttributedProjectFlockKandangId = &projectFlockKandangID + cloned.UsageQty = selectedQty + cloned.PendingQty = 0 + cloned.TotalWeight = delivery.TotalWeight * share + cloned.TotalPrice = delivery.TotalPrice * share + filtered = append(filtered, cloned) + } + + return filtered +} + func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) { var deliveryProducts []entity.MarketingDeliveryProduct var total int64 + baseDB := r.DB().WithContext(ctx) + singleAttributionQuery := commonRepo.MarketingDeliverySingleAttributionQuery(baseDB) db := r.DB().WithContext(ctx). Model(&entity.MarketingDeliveryProduct{}). + Select("marketing_delivery_products.*, mda_single.attributed_project_flock_kandang_id"). + Joins("LEFT JOIN (?) AS mda_single ON mda_single.marketing_delivery_product_id = marketing_delivery_products.id", singleAttributionQuery). Preload("MarketingProduct", func(db *gorm.DB) *gorm.DB { return db. Preload("Marketing"). @@ -237,6 +337,9 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Preload("ProductWarehouse.ProjectFlockKandang"). Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock") }). + Preload("AttributedProjectFlockKandang"). + Preload("AttributedProjectFlockKandang.ProjectFlock"). + Preload("AttributedProjectFlockKandang.Kandang"). Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id"). Where("marketing_delivery_products.delivery_date IS NOT NULL") @@ -292,22 +395,27 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C } if filters.AreaId > 0 || filters.LocationId > 0 || filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil { - db = db.Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). - Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id") - + buildAttrFilter := func() *gorm.DB { + return r.DB().WithContext(ctx). + Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))). + Select("1"). + Joins("JOIN project_flock_kandangs pfk ON pfk.id = mda.project_flock_kandang_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Where("mda.marketing_delivery_product_id = marketing_delivery_products.id") + } if filters.AreaId > 0 { - db = db.Where("project_flocks.area_id = ?", filters.AreaId) + db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.area_id = ?", filters.AreaId)) } if filters.LocationId > 0 { - db = db.Where("project_flocks.location_id = ?", filters.LocationId) + db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.location_id = ?", filters.LocationId)) } if filters.AllowedAreaIDs != nil { if len(filters.AllowedAreaIDs) == 0 { db = db.Where("1 = 0") } else { - db = db.Where("project_flocks.area_id IN ?", filters.AllowedAreaIDs) + db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.area_id IN ?", filters.AllowedAreaIDs)) } } @@ -315,7 +423,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C if len(filters.AllowedLocationIDs) == 0 { db = db.Where("1 = 0") } else { - db = db.Where("project_flocks.location_id IN ?", filters.AllowedLocationIDs) + db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.location_id IN ?", filters.AllowedLocationIDs)) } } } diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository_test.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository_test.go new file mode 100644 index 00000000..1263c2c2 --- /dev/null +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository_test.go @@ -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) + } +} diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 0680cf0e..2ae8dec9 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -643,6 +643,11 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor return nil } + affectedKandangIDs, err := s.marketingPopulationKandangIDsFromActiveAllocations(ctx, tx, deliveryProduct.Id) + if err != nil { + return err + } + deliveryProduct.UsageQty = 0 deliveryProduct.PendingQty = 0 if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil { @@ -670,6 +675,9 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil { return err } + if err := s.resyncPopulationUsageByKandangIDs(ctx, tx, affectedKandangIDs); err != nil { + return err + } releasedUsage := currentUsage - deliveryProduct.UsageQty if actorID > 0 && releasedUsage > 0 { @@ -725,29 +733,378 @@ func (s deliveryOrdersService) allocatePopulationForMarketingDelivery( return nil } - pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil) + exactAllocations, err := s.findDirectPopulationAllocationsForMarketing(ctx, tx, deliveryProduct.Id) if err != nil { return err } - if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 { + if len(exactAllocations) > 0 { + if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil { + return err + } + if err := s.applyDirectPopulationAllocationsForMarketing(ctx, tx, productWarehouseID, deliveryProduct.Id, exactAllocations); err != nil { + return err + } + return s.resyncPopulationUsageByKandangIDs(ctx, tx, marketingAllocationKandangIDs(exactAllocations)) + } + + sourceGroups, err := s.findPopulationSourceGroupsForMarketing(ctx, tx, deliveryProduct.Id, productWarehouseID) + if err != nil { + return err + } + if len(sourceGroups) == 0 { return nil } - - populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(ctx, *pw.ProjectFlockKandangId, productWarehouseID) - if err != nil { + if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil { return err } - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery") + for _, group := range sourceGroups { + populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID( + ctx, + group.ProjectFlockKandangID, + group.ProductWarehouseID, + ) + if err != nil { + return err + } + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery") + } + if err := s.allocatePopulationConsumptionWithoutRelease( + ctx, + tx, + populations, + productWarehouseID, + deliveryProduct.Id, + 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 } - return fifoV2.AllocatePopulationConsumption( - ctx, - tx, - populations, - productWarehouseID, - fifo.UsableKeyMarketingDelivery.String(), - deliveryProduct.Id, - deliveryProduct.UsageQty, - ) + 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 } diff --git a/internal/modules/master/suppliers/dto/supplier_product.dto.go b/internal/modules/master/suppliers/dto/supplier_product.dto.go index a6178aaf..fd070161 100644 --- a/internal/modules/master/suppliers/dto/supplier_product.dto.go +++ b/internal/modules/master/suppliers/dto/supplier_product.dto.go @@ -30,6 +30,9 @@ func toSupplierProductDTOs(relations []entity.ProductSupplier) []SupplierProduct if product.Id == 0 { continue } + if len(product.Flags) == 0 { + continue + } flags := make([]string, len(product.Flags)) for i, f := range product.Flags { diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository.go b/internal/modules/master/warehouses/repositories/warehouse.repository.go index e879e01a..ff51a0b0 100644 --- a/internal/modules/master/warehouses/repositories/warehouse.repository.go +++ b/internal/modules/master/warehouses/repositories/warehouse.repository.go @@ -16,6 +16,7 @@ type WarehouseRepository interface { NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error) GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) + GetByKandangIDAndLocationID(ctx context.Context, kandangId uint, locationId uint) (*entity.Warehouse, error) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) } @@ -62,6 +63,20 @@ func (r *WarehouseRepositoryImpl) GetByKandangID(ctx context.Context, kandangId return &warehouse, nil } +func (r *WarehouseRepositoryImpl) GetByKandangIDAndLocationID(ctx context.Context, kandangId uint, locationId uint) (*entity.Warehouse, error) { + var warehouse entity.Warehouse + err := r.db.WithContext(ctx). + Where("kandang_id = ?", kandangId). + Where("location_id = ?", locationId). + Where("deleted_at IS NULL"). + Order("id ASC"). + First(&warehouse).Error + if err != nil { + return nil, err + } + return &warehouse, nil +} + func (r *WarehouseRepositoryImpl) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) { var warehouse entity.Warehouse err := r.db.WithContext(ctx). diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository_test.go b/internal/modules/master/warehouses/repositories/warehouse.repository_test.go new file mode 100644 index 00000000..f759cbc0 --- /dev/null +++ b/internal/modules/master/warehouses/repositories/warehouse.repository_test.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + "testing" + + "github.com/glebarez/sqlite" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +func TestGetByKandangIDAndLocationIDReturnsLocationMatchedWarehouse(t *testing.T) { + db := setupWarehouseRepositoryTestDB(t) + repo := NewWarehouseRepository(db) + + warehouse, err := repo.GetByKandangIDAndLocationID(context.Background(), 5, 13) + if err != nil { + t.Fatalf("expected location-matched warehouse, got error: %v", err) + } + if warehouse.Id != 33 { + t.Fatalf("expected warehouse 33, got %d", warehouse.Id) + } +} + +func TestGetByKandangIDKeepsLegacyFirstWarehouseBehavior(t *testing.T) { + db := setupWarehouseRepositoryTestDB(t) + repo := NewWarehouseRepository(db) + + warehouse, err := repo.GetByKandangID(context.Background(), 5) + if err != nil { + t.Fatalf("expected warehouse, got error: %v", err) + } + if warehouse.Id != 17 { + t.Fatalf("expected legacy first warehouse 17, got %d", warehouse.Id) + } +} + +func setupWarehouseRepositoryTestDB(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) + } + + if err := db.AutoMigrate(&entity.Warehouse{}); err != nil { + t.Fatalf("failed migrating warehouses: %v", err) + } + + warehouses := []entity.Warehouse{ + {Id: 17, Name: "Cijangkar 1", Type: "KANDANG", AreaId: 1, LocationId: uintPtr(1), KandangId: uintPtr(5), CreatedBy: 1}, + {Id: 33, Name: "Gudang Cijangkar 1", Type: "KANDANG", AreaId: 1, LocationId: uintPtr(13), KandangId: uintPtr(5), CreatedBy: 1}, + } + if err := db.Create(&warehouses).Error; err != nil { + t.Fatalf("failed seeding warehouses: %v", err) + } + + return db +} + +func uintPtr(v uint) *uint { + return &v +} diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 33651482..1bade0a9 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -101,6 +101,25 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { } +func resolveWarehouseForProjectFlockKandang(ctx context.Context, warehouseRepo rWarehouse.WarehouseRepository, kandangID uint, locationID uint) (*entity.Warehouse, error) { + if warehouseRepo == nil { + return nil, gorm.ErrRecordNotFound + } + if kandangID == 0 { + return nil, gorm.ErrRecordNotFound + } + if locationID != 0 { + warehouse, err := warehouseRepo.GetByKandangIDAndLocationID(ctx, kandangID, locationID) + if err == nil { + return warehouse, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + return warehouseRepo.GetByKandangID(ctx, kandangID) +} + func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -183,7 +202,12 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") } - warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectFlockKandang.KandangId) + warehouse, err := resolveWarehouseForProjectFlockKandang( + c.Context(), + s.WarehouseRepo, + projectFlockKandang.KandangId, + projectFlockKandang.ProjectFlock.LocationId, + ) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found") } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 329fab80..1dc62062 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -87,6 +87,25 @@ func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository } } +func resolveWarehouseForProjectFlockKandang(ctx context.Context, warehouseRepo rWarehouse.WarehouseRepository, pfk *entity.ProjectFlockKandang) (*entity.Warehouse, error) { + if warehouseRepo == nil || pfk == nil { + return nil, gorm.ErrRecordNotFound + } + if pfk.KandangId == 0 { + return nil, gorm.ErrRecordNotFound + } + if pfk.ProjectFlock.LocationId != 0 { + warehouse, err := warehouseRepo.GetByKandangIDAndLocationID(ctx, pfk.KandangId, pfk.ProjectFlock.LocationId) + if err == nil { + return warehouse, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + return warehouseRepo.GetByKandangID(ctx, pfk.KandangId) +} + func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -241,7 +260,7 @@ func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, project return nil, nil } - warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectFlockKandang.Kandang.Id) + warehouse, err := resolveWarehouseForProjectFlockKandang(c.Context(), s.WarehouseRepo, projectFlockKandang) if err != nil || warehouse == nil { return nil, nil } @@ -300,7 +319,7 @@ func (s projectFlockKandangService) CheckClosing(c *fiber.Ctx, id uint) (*Closin stockRemain := make([]StockRemainingDetail, 0) if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil { - warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId) + warehouse, werr := resolveWarehouseForProjectFlockKandang(c.Context(), s.WarehouseRepo, pfk) if werr != nil { return nil, werr } @@ -464,7 +483,7 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati } if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil { - warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId) + warehouse, werr := resolveWarehouseForProjectFlockKandang(c.Context(), s.WarehouseRepo, pfk) if werr != nil { return nil, werr } diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index 361bf8a3..738dcf4c 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -18,6 +18,7 @@ type ProjectFlockPopulationRepository interface { GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, error) + ResyncUsageByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error @@ -167,3 +168,58 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKand return int64(math.Round(total)), nil } + +func (r *projectFlockPopulationRepositoryImpl) ResyncUsageByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return nil + } + + idsSubquery := ` + SELECT pfp.id + FROM project_flock_populations pfp + JOIN project_chickins pc ON pc.id = pfp.project_chickin_id + WHERE pc.project_flock_kandang_id = ? + ` + + updateWithAlloc := ` + UPDATE project_flock_populations p + SET total_used_qty = COALESCE(a.used, 0) + FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' + AND status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' + GROUP BY stockable_id + ) a + WHERE p.id = a.stockable_id + AND p.id IN (` + idsSubquery + `) + ` + + resetMissing := ` + UPDATE project_flock_populations p + SET total_used_qty = 0 + WHERE p.id IN (` + idsSubquery + `) + AND NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.stockable_id = p.id + ) + ` + + db := r.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil { + return err + } + if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil { + return err + } + return nil +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 4383ee4a..b200cb18 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -347,7 +347,10 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint func (r *projectFlockKandangRepositoryImpl) GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) { record := new(entity.ProjectFlockKandang) if err := r.db.WithContext(ctx). + Preload("Kandang"). + Preload("Kandang.Location"). Preload("ProjectFlock"). + Preload("ProjectFlock.Location"). First(record, id).Error; err != nil { return nil, err } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index f7812d0c..beaa0899 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -1075,6 +1075,25 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka return kandangRepository.NewKandangRepository(s.Repository.DB()) } +func resolveWarehouseByKandangAndLocation(ctx context.Context, warehouseRepo warehouseRepository.WarehouseRepository, kandangID uint, locationID uint) (*entity.Warehouse, error) { + if warehouseRepo == nil { + return nil, gorm.ErrRecordNotFound + } + if kandangID == 0 { + return nil, gorm.ErrRecordNotFound + } + if locationID != 0 { + warehouse, err := warehouseRepo.GetByKandangIDAndLocationID(ctx, kandangID, locationID) + if err == nil { + return warehouse, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + return warehouseRepo.GetByKandangID(ctx, kandangID) +} + func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx context.Context, dbTransaction *gorm.DB, records []*entity.ProjectFlockKandang) error { if len(records) == 0 { return nil @@ -1103,20 +1122,24 @@ func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx cont if dbTransaction != nil { db = dbTransaction } - var category string + type projectFlockMeta struct { + Category string `gorm:"column:category"` + LocationId uint `gorm:"column:location_id"` + } + var flockMeta projectFlockMeta if err := db.WithContext(ctx). Model(&entity.ProjectFlock{}). - Select("category"). + Select("category, location_id"). Where("id = ?", projectFlockID). - Scan(&category).Error; err != nil { + Scan(&flockMeta).Error; err != nil { return err } - if strings.TrimSpace(category) == "" { + if strings.TrimSpace(flockMeta.Category) == "" { return fiber.NewError(fiber.StatusBadRequest, "Project flock category tidak ditemukan") } prefixes := []string{"AYAM-"} - if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { + if strings.EqualFold(flockMeta.Category, string(utils.ProjectFlockCategoryLaying)) { prefixes = append(prefixes, "TELUR") } @@ -1134,7 +1157,7 @@ func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx cont continue } - warehouse, err := warehouseRepo.GetByKandangID(ctx, record.KandangId) + warehouse, err := resolveWarehouseByKandangAndLocation(ctx, warehouseRepo, record.KandangId, flockMeta.LocationId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse untuk kandang %d belum tersedia", record.KandangId)) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index fae5740d..6ea4c473 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -115,18 +115,21 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Depletions"). Preload("Depletions.ProductWarehouse"). Preload("Depletions.ProductWarehouse.Product"). + Preload("Depletions.ProductWarehouse.Product.Uom"). Preload("Depletions.ProductWarehouse.Warehouse"). Preload("Depletions.ProductWarehouse.Warehouse.Area"). Preload("Depletions.ProductWarehouse.Warehouse.Location"). Preload("Stocks"). Preload("Stocks.ProductWarehouse"). Preload("Stocks.ProductWarehouse.Product"). + Preload("Stocks.ProductWarehouse.Product.Uom"). Preload("Stocks.ProductWarehouse.Warehouse"). Preload("Stocks.ProductWarehouse.Warehouse.Area"). Preload("Stocks.ProductWarehouse.Warehouse.Location"). Preload("Eggs"). Preload("Eggs.ProductWarehouse"). Preload("Eggs.ProductWarehouse.Product"). + Preload("Eggs.ProductWarehouse.Product.Uom"). Preload("Eggs.ProductWarehouse.Warehouse"). Preload("Eggs.ProductWarehouse.Warehouse.Area"). Preload("Eggs.ProductWarehouse.Warehouse.Location") diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 6af96974..6f5fddb6 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -364,6 +364,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + if req.Eggs, err = s.resolveEggRequestsToFarmWarehouses(ctx, pfk, actorID, req.Eggs); err != nil { + return nil, err + } + if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err } @@ -375,12 +383,17 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return nil, err } - 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 { + 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 } - actorID, err := m.ActorIDFromContext(c) - if err != nil { + eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) + if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { return nil, err } var createdRecording entity.Recording @@ -444,11 +457,16 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureDepletionWithinPopulation(ctx, tx, req.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), 0); err != nil { return err } - sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId) - if err != nil { - return err - } + sourceProjectFlockKandangID := req.ProjectFlockKandangId for i := range mappedDepletions { + mappedDepletions[i].SourceProjectFlockKandangId = &sourceProjectFlockKandangID + if mappedDepletions[i].SourceProductWarehouseId != nil && *mappedDepletions[i].SourceProductWarehouseId != 0 { + continue + } + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId) + if err != nil { + return err + } mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID } } @@ -469,7 +487,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) + mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.ProjectFlockKandangId, createdRecording.CreatedBy, req.Eggs) if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { s.Log.Errorf("Failed to persist eggs: %+v", err) return err @@ -599,13 +617,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to list existing depletions: %+v", err) return err } - existingTotals := recordingutil.TotalsByWarehouse(existingDepletions, func(dep entity.RecordingDepletion) (uint, float64) { - return dep.ProductWarehouseId, dep.Qty + existingTotals := recordingutil.DepletionTotalsByRoute(existingDepletions, func(dep entity.RecordingDepletion) (uint, *uint, float64) { + return dep.ProductWarehouseId, dep.SourceProductWarehouseId, dep.Qty }) - incomingTotals := recordingutil.TotalsByWarehouse(req.Depletions, func(dep validation.Depletion) (uint, float64) { - return dep.ProductWarehouseId, dep.Qty + incomingTotals := recordingutil.DepletionTotalsByRoute(req.Depletions, func(dep validation.Depletion) (uint, *uint, float64) { + return dep.ProductWarehouseId, dep.SourceProductWarehouseId, dep.Qty }) - match := recordingutil.FloatMapsEqual(existingTotals, incomingTotals) + match := recordingutil.DepletionRouteMapsEqual(existingTotals, incomingTotals) if match { hasDepletionChanges = false } else { @@ -622,6 +640,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return err } + depletionSourceIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint { + if d.SourceProductWarehouseId == nil { + return 0 + } + return *d.SourceProductWarehouseId + }) + if err := s.ensureProductWarehousesByFlags(ctx, depletionSourceIDs, []string{"DOC", "PULLET", "LAYER"}, "depletion source"); err != nil { + return err + } if err := s.reflowResetRecordingDepletionsOut(ctx, tx, existingDepletions, note, actorID); err != nil { return err } @@ -639,11 +666,16 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil { return err } - sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) - if err != nil { - return err - } + sourceProjectFlockKandangID := recordingEntity.ProjectFlockKandangId for i := range mappedDepletions { + mappedDepletions[i].SourceProjectFlockKandangId = &sourceProjectFlockKandangID + if mappedDepletions[i].SourceProductWarehouseId != nil && *mappedDepletions[i].SourceProductWarehouseId != 0 { + continue + } + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) + if err != nil { + return err + } mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID } } @@ -668,6 +700,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to list existing eggs: %+v", err) return err } + normalizeEggWarehouses, err := s.shouldNormalizeEggRequestsOnUpdate(ctx, existingEggs) + if err != nil { + return err + } + if normalizeEggWarehouses { + if req.Eggs, err = s.resolveEggRequestsToFarmWarehouses(ctx, pfkForRoute, actorID, req.Eggs); err != nil { + return err + } + } existingTotals := recordingutil.EggTotalsByWarehouse(existingEggs, func(egg entity.RecordingEgg) (uint, int, *float64) { return egg.ProductWarehouseId, egg.Qty, egg.Weight }) @@ -709,7 +750,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) + mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.ProjectFlockKandangId, recordingEntity.CreatedBy, req.Eggs) if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { s.Log.Errorf("Failed to update eggs: %+v", err) return err @@ -1536,6 +1577,194 @@ func boolPtr(value bool) *bool { return &v } +func (s *recordingService) resolveEggRequestsToFarmWarehouses( + ctx context.Context, + pfk *entity.ProjectFlockKandang, + actorID uint, + eggs []validation.Egg, +) ([]validation.Egg, error) { + if len(eggs) == 0 { + return eggs, nil + } + + locationID, farmName := farmContextFromProjectFlockKandang(pfk) + if locationID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Farm recording tidak valid") + } + + farmWarehouse, err := s.findFirstFarmWarehouse(ctx, locationID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Farm %s belum memiliki gudang", farmName)) + } + s.Log.Errorf("Failed to resolve farm warehouse for egg recording: %+v", err) + return nil, err + } + + idSet := make(map[uint]struct{}, len(eggs)) + for _, egg := range eggs { + if egg.ProductWarehouseId != 0 { + idSet[egg.ProductWarehouseId] = struct{}{} + } + } + if len(idSet) == 0 { + return eggs, nil + } + + ids := make([]uint, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + + var sourceWarehouses []entity.ProductWarehouse + if err := s.ProductWarehouseRepo.DB().WithContext(ctx). + Preload("Warehouse"). + Where("id IN ?", ids). + Find(&sourceWarehouses).Error; err != nil { + s.Log.Errorf("Failed to load egg source product warehouses: %+v", err) + return nil, err + } + if len(sourceWarehouses) != len(ids) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Product warehouse telur tidak ditemukan") + } + + sourceByID := make(map[uint]entity.ProductWarehouse, len(sourceWarehouses)) + resolvedBySource := make(map[uint]uint, len(sourceWarehouses)) + for _, source := range sourceWarehouses { + if err := ensureEggSourceMatchesRecordingScope(source, locationID, pfk.KandangId); err != nil { + return nil, err + } + sourceByID[source.Id] = source + } + + normalized := make([]validation.Egg, len(eggs)) + copy(normalized, eggs) + for i := range normalized { + source := sourceByID[normalized[i].ProductWarehouseId] + if resolvedID, ok := resolvedBySource[source.Id]; ok { + normalized[i].ProductWarehouseId = resolvedID + continue + } + + resolvedID, err := s.ProductWarehouseRepo.EnsureProductWarehouse(ctx, source.ProductId, farmWarehouse.Id, nil, actorID) + if err != nil { + s.Log.Errorf("Failed to ensure egg farm product warehouse: %+v", err) + return nil, err + } + resolvedBySource[source.Id] = resolvedID + normalized[i].ProductWarehouseId = resolvedID + } + + return normalized, nil +} + +func farmContextFromProjectFlockKandang(pfk *entity.ProjectFlockKandang) (uint, string) { + if pfk == nil { + return 0, "tidak diketahui" + } + + if pfk.ProjectFlock.LocationId != 0 { + name := strings.TrimSpace(pfk.ProjectFlock.Location.Name) + if name == "" { + name = strings.TrimSpace(pfk.Kandang.Location.Name) + } + if name == "" { + name = fmt.Sprintf("#%d", pfk.ProjectFlock.LocationId) + } + return pfk.ProjectFlock.LocationId, name + } + + if pfk.Kandang.LocationId != 0 { + name := strings.TrimSpace(pfk.Kandang.Location.Name) + if name == "" { + name = fmt.Sprintf("#%d", pfk.Kandang.LocationId) + } + return pfk.Kandang.LocationId, name + } + + return 0, "tidak diketahui" +} + +func (s *recordingService) findFirstFarmWarehouse(ctx context.Context, locationID uint) (*entity.Warehouse, error) { + var warehouse entity.Warehouse + if err := s.Repository.DB().WithContext(ctx). + Model(&entity.Warehouse{}). + Where("location_id = ? AND type = ?", locationID, utils.WarehouseTypeLokasi). + Order("id ASC"). + First(&warehouse).Error; err != nil { + return nil, err + } + return &warehouse, nil +} + +func ensureEggSourceMatchesRecordingScope(source entity.ProductWarehouse, locationID uint, kandangID uint) error { + if source.Id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Product warehouse telur tidak ditemukan") + } + if source.ProductId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Produk telur tidak valid") + } + if source.WarehouseId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Gudang telur tidak valid") + } + if source.Warehouse.LocationId == nil || *source.Warehouse.LocationId != locationID { + return fiber.NewError(fiber.StatusBadRequest, "Produk telur harus berasal dari farm yang sama") + } + + switch strings.ToUpper(strings.TrimSpace(source.Warehouse.Type)) { + case string(utils.WarehouseTypeLokasi): + return nil + case string(utils.WarehouseTypeKandang): + if source.Warehouse.KandangId == nil || *source.Warehouse.KandangId != kandangID { + return fiber.NewError(fiber.StatusBadRequest, "Produk telur harus berasal dari kandang recording yang sama") + } + return nil + default: + return fiber.NewError(fiber.StatusBadRequest, "Produk telur harus berasal dari gudang farm atau kandang recording") + } +} + +func (s *recordingService) shouldNormalizeEggRequestsOnUpdate(ctx context.Context, existingEggs []entity.RecordingEgg) (bool, error) { + if len(existingEggs) == 0 { + return true, nil + } + + idSet := make(map[uint]struct{}, len(existingEggs)) + for _, egg := range existingEggs { + if egg.ProductWarehouseId != 0 { + idSet[egg.ProductWarehouseId] = struct{}{} + } + } + if len(idSet) == 0 { + return true, nil + } + + ids := make([]uint, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + + var productWarehouses []entity.ProductWarehouse + if err := s.ProductWarehouseRepo.DB().WithContext(ctx). + Preload("Warehouse"). + Where("id IN ?", ids). + Find(&productWarehouses).Error; err != nil { + s.Log.Errorf("Failed to load existing egg product warehouses: %+v", err) + return false, err + } + if len(productWarehouses) != len(ids) { + return false, fiber.NewError(fiber.StatusBadRequest, "Product warehouse telur tidak ditemukan") + } + + for _, productWarehouse := range productWarehouses { + if strings.EqualFold(strings.TrimSpace(productWarehouse.Warehouse.Type), string(utils.WarehouseTypeLokasi)) { + return true, nil + } + } + + return false, nil +} + func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { idSet := make(map[uint]struct{}) @@ -1548,6 +1777,9 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v if dep.ProductWarehouseId != 0 { idSet[dep.ProductWarehouseId] = struct{}{} } + if dep.SourceProductWarehouseId != nil && *dep.SourceProductWarehouseId != 0 { + idSet[*dep.SourceProductWarehouseId] = struct{}{} + } } for _, egg := range eggs { if egg.ProductWarehouseId != 0 { @@ -2572,12 +2804,17 @@ func (s *recordingService) allocatePopulationForDepletion( } var projectFlockKandangID uint - if err := tx.WithContext(ctx). - Table("recordings"). - Select("project_flock_kandangs_id"). - Where("id = ?", depletion.RecordingId). - Scan(&projectFlockKandangID).Error; err != nil { - return err + if depletion.SourceProjectFlockKandangId != nil { + projectFlockKandangID = *depletion.SourceProjectFlockKandangId + } + if projectFlockKandangID == 0 { + if err := tx.WithContext(ctx). + Table("recordings"). + Select("project_flock_kandangs_id"). + Where("id = ?", depletion.RecordingId). + Scan(&projectFlockKandangID).Error; err != nil { + return err + } } if projectFlockKandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak ditemukan untuk depletion") diff --git a/internal/modules/production/recordings/services/recording.service_test.go b/internal/modules/production/recordings/services/recording.service_test.go new file mode 100644 index 00000000..f1bc6abf --- /dev/null +++ b/internal/modules/production/recordings/services/recording.service_test.go @@ -0,0 +1,195 @@ +package service + +import ( + "context" + "strings" + "testing" + + "github.com/glebarez/sqlite" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + "gorm.io/gorm" +) + +func TestResolveEggRequestsToFarmWarehousesChoosesFirstFarmWarehouse(t *testing.T) { + db := setupRecordingServiceTestDB(t) + repo := repository.NewRecordingRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + svc := &recordingService{ + Log: nil, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + } + + pfk := &entity.ProjectFlockKandang{ + Id: 10, + KandangId: 59, + ProjectFlock: entity.ProjectFlock{ + LocationId: 16, + Location: entity.Location{Name: "Jamali"}, + }, + Kandang: entity.Kandang{ + Id: 59, + LocationId: 16, + Location: entity.Location{Name: "Jamali"}, + }, + } + + got, err := svc.resolveEggRequestsToFarmWarehouses(context.Background(), pfk, 9, []validation.Egg{ + {ProductWarehouseId: 101, Qty: 120}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 egg row, got %d", len(got)) + } + if got[0].ProductWarehouseId == 101 { + t.Fatalf("expected egg warehouse to be remapped to farm warehouse") + } + + var resolved entity.ProductWarehouse + if err := db.WithContext(context.Background()). + Preload("Warehouse"). + First(&resolved, got[0].ProductWarehouseId).Error; err != nil { + t.Fatalf("failed to load resolved product warehouse: %v", err) + } + + if resolved.ProductId != 8 { + t.Fatalf("expected product_id 8, got %d", resolved.ProductId) + } + if resolved.WarehouseId != 21 { + t.Fatalf("expected first farm warehouse id 21, got %d", resolved.WarehouseId) + } + if resolved.ProjectFlockKandangId != nil { + t.Fatalf("expected farm-level product warehouse to remain shared, got pfk %+v", resolved.ProjectFlockKandangId) + } +} + +func TestResolveEggRequestsToFarmWarehousesFailsWhenFarmHasNoWarehouse(t *testing.T) { + db := setupRecordingServiceTestDB(t) + if err := db.Exec("DELETE FROM warehouses WHERE type = 'LOKASI'").Error; err != nil { + t.Fatalf("failed to remove farm warehouses: %v", err) + } + + repo := repository.NewRecordingRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + svc := &recordingService{ + Log: nil, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + } + + pfk := &entity.ProjectFlockKandang{ + Id: 10, + KandangId: 59, + ProjectFlock: entity.ProjectFlock{ + LocationId: 16, + Location: entity.Location{Name: "Jamali"}, + }, + } + + _, err := svc.resolveEggRequestsToFarmWarehouses(context.Background(), pfk, 9, []validation.Egg{ + {ProductWarehouseId: 101, Qty: 120}, + }) + if err == nil { + t.Fatal("expected validation error when farm warehouse is missing") + } + if !strings.Contains(err.Error(), "Farm Jamali belum memiliki gudang") { + t.Fatalf("expected missing farm warehouse error, got %v", err) + } +} + +func TestShouldNormalizeEggRequestsOnUpdatePreservesHistoricalKandangEggs(t *testing.T) { + db := setupRecordingServiceTestDB(t) + repo := repository.NewRecordingRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + svc := &recordingService{ + Log: nil, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + } + + shouldNormalize, err := svc.shouldNormalizeEggRequestsOnUpdate(context.Background(), []entity.RecordingEgg{ + {ProductWarehouseId: 101, Qty: 120}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if shouldNormalize { + t.Fatal("expected historical kandang-level egg rows to remain kandang-level on update") + } +} + +func TestShouldNormalizeEggRequestsOnUpdateNormalizesFarmLevelEggs(t *testing.T) { + db := setupRecordingServiceTestDB(t) + if err := db.Exec(`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES (201, 8, 21, NULL, 300)`).Error; err != nil { + t.Fatalf("failed to insert farm-level egg warehouse: %v", err) + } + + repo := repository.NewRecordingRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + svc := &recordingService{ + Log: nil, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + } + + shouldNormalize, err := svc.shouldNormalizeEggRequestsOnUpdate(context.Background(), []entity.RecordingEgg{ + {ProductWarehouseId: 201, Qty: 120}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !shouldNormalize { + t.Fatal("expected farm-level egg rows to keep using farm normalization on update") + } +} + +func setupRecordingServiceTestDB(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 locations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + )`, + `CREATE TABLE warehouses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + location_id INTEGER NULL, + kandang_id INTEGER NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE product_warehouses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL, + warehouse_id INTEGER NOT NULL, + project_flock_kandang_id INTEGER NULL, + qty NUMERIC NULL + )`, + `INSERT INTO locations (id, name) VALUES (16, 'Jamali')`, + `INSERT INTO warehouses (id, name, type, location_id, kandang_id, deleted_at) VALUES + (21, 'Gudang Farm Jamali A', 'LOKASI', 16, NULL, NULL), + (25, 'Gudang Farm Jamali B', 'LOKASI', 16, NULL, NULL), + (46, 'Gudang Jamali 1', 'KANDANG', 16, 59, NULL)`, + `INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES + (101, 8, 46, 10, 500)`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +} diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 647ea37c..2e6ebbdb 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -9,8 +9,9 @@ type ( } Depletion struct { - ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Qty float64 `json:"qty" validate:"required,gte=0"` + 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"` } Egg struct { diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index 2cb0ba75..56e6b8c6 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -143,6 +143,17 @@ func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uin return r.DB().WithContext(ctx).Create(&items).Error } +func (r *PurchaseRepositoryImpl) purchaseItemExists(ctx context.Context, purchaseID uint, itemID uint) (bool, error) { + var count int64 + if err := r.DB().WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("purchase_id = ? AND id = ?", purchaseID, itemID). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + type PurchasePricingUpdate struct { ItemID uint ProductID *uint @@ -197,7 +208,13 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( return result.Error } if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound + exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID) + if err != nil { + return err + } + if !exists { + return gorm.ErrRecordNotFound + } } } @@ -251,7 +268,13 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( return result.Error } if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound + exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID) + if err != nil { + return err + } + if !exists { + return gorm.ErrRecordNotFound + } } } diff --git a/internal/modules/purchases/repositories/purchase.repository_test.go b/internal/modules/purchases/repositories/purchase.repository_test.go new file mode 100644 index 00000000..cf6ddb5d --- /dev/null +++ b/internal/modules/purchases/repositories/purchase.repository_test.go @@ -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 +} diff --git a/internal/modules/purchases/services/fifo_stock_v2_helper.go b/internal/modules/purchases/services/fifo_stock_v2_helper.go index e0b619a9..1e20c76d 100644 --- a/internal/modules/purchases/services/fifo_stock_v2_helper.go +++ b/internal/modules/purchases/services/fifo_stock_v2_helper.go @@ -2,12 +2,14 @@ package service import ( "context" + "errors" "fmt" "strings" "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -45,6 +47,17 @@ func reflowPurchaseScope( AsOf: asOf, Tx: tx, }) + if err != nil { + return err + } + + _, err = fifoStockV2Svc.Recalculate(ctx, commonSvc.FifoStockV2RecalculateRequest{ + ProductWarehouseIDs: []uint{productWarehouseID}, + FlagGroupCodes: []string{flagGroupCode}, + AsOf: asOf, + FixDrift: true, + Tx: tx, + }) return err } @@ -76,11 +89,53 @@ func resolvePurchaseFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB Order("rr.id ASC"). Limit(1). Take(&selected).Error + if err == nil { + return strings.TrimSpace(selected.FlagGroupCode), nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + + type categoryRow struct { + CategoryCode string `gorm:"column:category_code"` + } + + var category categoryRow + err = tx.WithContext(ctx). + Table("product_warehouses pw"). + Select("pc.code AS category_code"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN product_categories pc ON pc.id = p.product_category_id"). + Where("pw.id = ?", productWarehouseID). + Limit(1). + Take(&category).Error if err != nil { return "", err } - return strings.TrimSpace(selected.FlagGroupCode), nil + flagGroupCode := utils.LegacyFlagGroupCodeByProductCategoryCode(category.CategoryCode) + if flagGroupCode == "" { + return "", gorm.ErrRecordNotFound + } + + var matched int64 + err = tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", purchaseStockableLane). + Where("rr.function_code = ?", purchaseInFunctionCode). + Where("rr.source_table = ?", purchaseSourceTable). + Where("rr.flag_group_code = ?", flagGroupCode). + Count(&matched).Error + if err != nil { + return "", err + } + if matched == 0 { + return "", gorm.ErrRecordNotFound + } + + return flagGroupCode, nil } func assignEarliestAsOf(m map[uint]time.Time, productWarehouseID uint, asOf time.Time) { diff --git a/internal/modules/purchases/services/fifo_stock_v2_helper_test.go b/internal/modules/purchases/services/fifo_stock_v2_helper_test.go new file mode 100644 index 00000000..604cbe11 --- /dev/null +++ b/internal/modules/purchases/services/fifo_stock_v2_helper_test.go @@ -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 +} diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index b12fdfeb..30375b88 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -53,17 +53,18 @@ type ProductRelationDTOFixed struct { SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64, agingMap map[int]int) []RepportMarketingItemDTO { +func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppByDelivery map[uint]float64, categoryByDelivery map[uint]string, agingMap map[int]int) []RepportMarketingItemDTO { items := make([]RepportMarketingItemDTO, 0, len(mdps)) for _, mdp := range mdps { - hppPerKg := float64(0) - category := "" - if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { - if hpp, exists := hppMap[projectFlockKandang.ProjectFlockId]; exists { - hppPerKg = hpp + hppPerKg := hppByDelivery[mdp.Id] + category := categoryByDelivery[mdp.Id] + if category == "" { + if projectFlockKandang := mdp.AttributedProjectFlockKandang; projectFlockKandang != nil { + category = projectFlockKandang.ProjectFlock.Category + } else if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { + category = projectFlockKandang.ProjectFlock.Category } - category = projectFlockKandang.ProjectFlock.Category } soDate := time.Time{} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 3e002e2c..3468f266 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -18,6 +18,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" @@ -215,39 +216,95 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - projectFlockIDMap := make(map[uint]bool) - hppMap := make(map[uint]float64) + deliveryIDs := make([]uint, 0, len(deliveryProducts)) + for _, delivery := range deliveryProducts { + deliveryIDs = append(deliveryIDs, delivery.Id) + } - for _, dp := range deliveryProducts { - if projectFlockKandang := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { - projectFlockID := projectFlockKandang.ProjectFlockId - if projectFlockID > 0 && !projectFlockIDMap[projectFlockID] { - projectFlockIDMap[projectFlockID] = true + attributionRows, err := s.MarketingDeliveryRepo.GetAttributionRowsByDeliveryProductIDs(c.Context(), deliveryIDs) + if err != nil { + return nil, 0, err + } - category := projectFlockKandang.ProjectFlock.Category - if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryLaying { - if s.HppSvc != nil { - hppCost, err := s.HppSvc.CalculateHppCost(projectFlockID, nil) - if err != nil { - hppMap[projectFlockID] = 0.0 - } else if hppCost != nil { - hppMap[projectFlockID] = hppCost.Real.HargaKg - } else { - hppMap[projectFlockID] = 0.0 - } - } else { - hppMap[projectFlockID] = 0.0 - } - } else { + hppByDelivery := buildMarketingHppByDelivery(c.Context(), s.HppSvc, attributionRows) + categoryByDelivery := buildMarketingCategoryByDelivery(deliveryProducts, attributionRows) - hppMap[projectFlockID] = 0.0 + items := dto.ToMarketingReportItems(deliveryProducts, hppByDelivery, categoryByDelivery, agingMap) + return items, total, nil +} + +func buildMarketingHppByDelivery( + ctx context.Context, + hppSvc approvalService.HppService, + attributionRows []commonRepo.MarketingDeliveryAttributionRow, +) map[uint]float64 { + if len(attributionRows) == 0 { + return map[uint]float64{} + } + + hppByKandang := make(map[uint]float64) + weightedByDelivery := make(map[uint]float64) + totalQtyByDelivery := make(map[uint]float64) + + for _, row := range attributionRows { + if row.MarketingDeliveryProductID == 0 || row.ProjectFlockKandangID == 0 || row.AllocatedQty <= 0 { + continue + } + + hppPerKg, exists := hppByKandang[row.ProjectFlockKandangID] + if !exists { + hppPerKg = 0 + if hppSvc != nil && utils.ProjectFlockCategory(row.ProjectFlockCategory) == utils.ProjectFlockCategoryLaying { + if hppCost, err := hppSvc.CalculateHppCost(row.ProjectFlockKandangID, nil); err == nil && hppCost != nil { + hppPerKg = hppCost.Real.HargaKg } } + hppByKandang[row.ProjectFlockKandangID] = hppPerKg + } + + weightedByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty * hppPerKg + totalQtyByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty + } + + result := make(map[uint]float64, len(totalQtyByDelivery)) + for deliveryID, totalQty := range totalQtyByDelivery { + if totalQty <= 0 { + continue + } + result[deliveryID] = weightedByDelivery[deliveryID] / totalQty + } + + return result +} + +func buildMarketingCategoryByDelivery( + deliveryProducts []entity.MarketingDeliveryProduct, + attributionRows []commonRepo.MarketingDeliveryAttributionRow, +) map[uint]string { + result := make(map[uint]string, len(deliveryProducts)) + for _, row := range attributionRows { + if row.MarketingDeliveryProductID == 0 || strings.TrimSpace(row.ProjectFlockCategory) == "" { + continue + } + if _, exists := result[row.MarketingDeliveryProductID]; !exists { + result[row.MarketingDeliveryProductID] = row.ProjectFlockCategory } } - items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap) - return items, total, nil + 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) { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index c263180b..dfe4ef6e 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -1,6 +1,7 @@ package utils import ( + "slices" "strings" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -129,10 +130,23 @@ var productSubFlagToFlag = func() map[FlagType]FlagType { }() var productAllowWithoutSubFlagByFlag = map[FlagType]bool{ - FlagAyam: true, - FlagPakan: false, - FlagOVK: false, - FlagTelur: false, + FlagAyam: true, + FlagPakan: false, + FlagOVK: 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{ @@ -228,6 +242,52 @@ func ProductFlagAllowWithoutSubFlag(flag FlagType) bool { return allow } +func LegacyProductCategoryCodesForFlags(flags []string) []string { + if len(flags) == 0 { + return nil + } + + requested := make(map[FlagType]struct{}, len(flags)) + for _, flag := range flags { + canonical := CanonicalFlagType(flag) + if canonical == "" { + continue + } + requested[canonical] = struct{}{} + } + if len(requested) == 0 { + return nil + } + + codes := make([]string, 0, len(legacyProductCategoryFlagsByCode)) + for code, supportedFlags := range legacyProductCategoryFlagsByCode { + for _, supportedFlag := range supportedFlags { + if _, ok := requested[canonicalizeFlagType(supportedFlag)]; ok { + codes = append(codes, code) + break + } + } + } + + slices.Sort(codes) + return codes +} + +func LegacyFlagGroupCodeByProductCategoryCode(code string) string { + switch strings.ToUpper(strings.TrimSpace(code)) { + case "DOC", "PLT": + return "AYAM" + case "EGG": + return "TELUR" + case "RAW", "PST", "STR", "FSR": + return "PAKAN" + case "OBT", "VTM", "KMA": + return "OVK" + default: + return "" + } +} + func IsProductMainFlag(flag FlagType) bool { canonical := canonicalizeFlagType(flag) for _, f := range productMainFlags { diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index d03ea800..49335c90 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -31,44 +31,65 @@ func MapDepletions(recordingID uint, items []validation.Depletion) []entity.Reco return nil } - aggregate := make(map[uint]float64, len(items)) + type depletionKey struct { + ProductWarehouseID uint + SourceProductWarehouseID uint + } + + aggregate := make(map[depletionKey]float64, len(items)) for _, item := range items { if item.ProductWarehouseId == 0 || item.Qty == 0 { continue } - aggregate[item.ProductWarehouseId] += item.Qty + key := depletionKey{ProductWarehouseID: item.ProductWarehouseId} + if item.SourceProductWarehouseId != nil { + key.SourceProductWarehouseID = *item.SourceProductWarehouseId + } + aggregate[key] += item.Qty } if len(aggregate) == 0 { return nil } result := make([]entity.RecordingDepletion, 0, len(aggregate)) - for warehouseID, qty := range aggregate { + for key, qty := range aggregate { if qty == 0 { continue } + var sourceWarehouseID *uint + if key.SourceProductWarehouseID != 0 { + sourceWarehouseID = new(uint) + *sourceWarehouseID = key.SourceProductWarehouseID + } result = append(result, entity.RecordingDepletion{ - RecordingId: recordingID, - ProductWarehouseId: warehouseID, - Qty: qty, + RecordingId: recordingID, + ProductWarehouseId: key.ProductWarehouseID, + SourceProductWarehouseId: sourceWarehouseID, + Qty: qty, }) } return result } -func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity.RecordingEgg { +func MapEggs(recordingID uint, projectFlockKandangID uint, createdBy uint, items []validation.Egg) []entity.RecordingEgg { if len(items) == 0 { return nil } result := make([]entity.RecordingEgg, 0, len(items)) for _, item := range items { + var sourceProjectFlockKandangID *uint + if projectFlockKandangID != 0 { + sourceProjectFlockKandangID = new(uint) + *sourceProjectFlockKandangID = projectFlockKandangID + } result = append(result, entity.RecordingEgg{ - RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - Qty: item.Qty, - Weight: item.Weight, - CreatedBy: createdBy, + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + ProjectFlockKandangId: sourceProjectFlockKandangID, + Qty: item.Qty, + Weight: item.Weight, + CreatedBy: createdBy, }) } return result @@ -79,6 +100,11 @@ type EggTotals struct { Weight float64 } +type DepletionRoute struct { + ProductWarehouseId uint + SourceProductWarehouseId uint +} + func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 { return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) { var usage float64 @@ -121,6 +147,19 @@ func EggTotalsEqual(a, b map[uint]EggTotals) bool { return true } +func DepletionRouteMapsEqual(a, b map[DepletionRoute]float64) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok || !floatNearlyEqual(value, other) { + return false + } + } + return true +} + func floatNearlyEqual(a, b float64) bool { return a-b <= 0.000001 && b-a <= 0.000001 } @@ -134,6 +173,19 @@ func TotalsByWarehouse[T any](items []T, get func(T) (uint, float64)) map[uint]f return result } +func DepletionTotalsByRoute[T any](items []T, get func(T) (uint, *uint, float64)) map[DepletionRoute]float64 { + result := make(map[DepletionRoute]float64) + for _, item := range items { + productWarehouseID, sourceProductWarehouseID, qty := get(item) + key := DepletionRoute{ProductWarehouseId: productWarehouseID} + if sourceProductWarehouseID != nil { + key.SourceProductWarehouseId = *sourceProductWarehouseID + } + result[key] += qty + } + return result +} + func EggTotalsByWarehouse[T any](items []T, get func(T) (uint, int, *float64)) map[uint]EggTotals { result := make(map[uint]EggTotals) for _, item := range items { diff --git a/internal/utils/recording/util.recording_test.go b/internal/utils/recording/util.recording_test.go new file mode 100644 index 00000000..9ce0ff75 --- /dev/null +++ b/internal/utils/recording/util.recording_test.go @@ -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) + } +}