From d226d5f7f3365432d25ef859e2fb8d86a0fcd12f Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 25 Mar 2026 12:42:05 +0700 Subject: [PATCH 1/7] fix asof chickin adjustment --- .../production/chickins/services/chickin.service.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 3a342646..33651482 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -37,6 +37,7 @@ import ( const ( chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif" chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, Transfer, Adjustment, dan Transfer to Laying terlebih dahulu." + chickinAdjustmentSourceTable = "adjustment_stocks" ) type ChickinService interface { @@ -577,7 +578,7 @@ func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx Lane: commonSvc.FifoStockV2Lane("STOCKABLE"), AllocationPurpose: entity.StockAllocationPurposeConsume, ProductWarehouseID: productWarehouseID, - AsOf: asOf, + AsOf: nil, Limit: 10000, Tx: tx, }) @@ -586,10 +587,16 @@ func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx } available := 0.0 + hasAsOf := asOf != nil && !asOf.IsZero() for _, row := range gatherRows { if row.AvailableQuantity <= 0 { continue } + if hasAsOf && + !strings.EqualFold(strings.TrimSpace(row.SourceTable), chickinAdjustmentSourceTable) && + row.SortAt.After(*asOf) { + continue + } available += row.AvailableQuantity } return available, nil From 63cf0c6fac41b9fffc22ffd757b81cc5e723817a Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 27 Mar 2026 12:11:17 +0700 Subject: [PATCH 2/7] add restrict delete master data kandang group --- .../repositories/kandang_group.repository.go | 19 +++++++++++++++++++ .../services/kandang_group.service.go | 10 ++++++++++ 2 files changed, 29 insertions(+) diff --git a/internal/modules/master/kandang-groups/repositories/kandang_group.repository.go b/internal/modules/master/kandang-groups/repositories/kandang_group.repository.go index a7c579b7..8aa58cd1 100644 --- a/internal/modules/master/kandang-groups/repositories/kandang_group.repository.go +++ b/internal/modules/master/kandang-groups/repositories/kandang_group.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "gorm.io/gorm" @@ -14,6 +15,7 @@ type KandangGroupRepository interface { LocationExists(ctx context.Context, locationId uint) (bool, error) PicExists(ctx context.Context, picId uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + HasDailyChecklistRelation(ctx context.Context, kandangGroupId uint) (bool, error) } type KandangGroupRepositoryImpl struct { @@ -39,3 +41,20 @@ func (r *KandangGroupRepositoryImpl) PicExists(ctx context.Context, picId uint) func (r *KandangGroupRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.KandangGroup](ctx, r.db, name, excludeID) } + +func (r *KandangGroupRepositoryImpl) HasDailyChecklistRelation(ctx context.Context, kandangGroupId uint) (bool, error) { + var marker int + err := r.db.WithContext(ctx). + Model(&entity.DailyChecklist{}). + Select("1"). + Where("kandang_id = ?", kandangGroupId). + Limit(1). + Take(&marker).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} diff --git a/internal/modules/master/kandang-groups/services/kandang_group.service.go b/internal/modules/master/kandang-groups/services/kandang_group.service.go index 276c20b5..b569ca97 100644 --- a/internal/modules/master/kandang-groups/services/kandang_group.service.go +++ b/internal/modules/master/kandang-groups/services/kandang_group.service.go @@ -226,6 +226,16 @@ func (s kandangGroupService) DeleteOne(c *fiber.Ctx, id uint) error { if err != nil { return err } + + hasDailyChecklistRelation, err := s.Repository.HasDailyChecklistRelation(c.Context(), id) + if err != nil { + s.Log.Errorf("Failed to check daily checklist relation for kandang group %d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check kandang group relation") + } + if hasDailyChecklistRelation { + return fiber.NewError(fiber.StatusConflict, "Kandang group tidak boleh dihapus karena masih memiliki relasi daily checklist") + } + if len(kandangGroup.Kandangs) > 0 { return fiber.NewError(fiber.StatusConflict, "Kandang group tidak boleh dihapus karena masih memiliki relasi kandang") } From be008371481f1729d85996e4238694fca5b192fe Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Mon, 30 Mar 2026 13:40:29 +0700 Subject: [PATCH 3/7] codex: initiated changes --- docs/farm_stock_attribution_design.md | 31 ++ ...rketing_delivery_attribution.repository.go | 224 ++++++++++ ...ng_delivery_attribution.repository_test.go | 147 +++++++ ...attribution_fields_for_farm_stock.down.sql | 18 + ...g_attribution_fields_for_farm_stock.up.sql | 61 +++ .../entities/marketing_delivery_product.go | 22 +- internal/entities/recording_depletion.go | 20 +- internal/entities/recording_egg.go | 30 +- .../closings/dto/closingMarketing.dto.go | 25 +- .../repositories/closing.repository.go | 128 ++++-- .../product_warehouse.repository.go | 90 ++-- .../product_warehouse.repository_test.go | 117 ++++++ .../salesorder_delivery_product.repository.go | 362 ++++++++++------ ...sorder_delivery_product.repository_test.go | 42 ++ .../services/deliveryorder.service.go | 389 +++++++++++++++++- .../project_flock_population_repository.go | 56 +++ .../recordings/services/recording.service.go | 78 +++- .../validations/recording.validation.go | 5 +- .../repports/dto/repportMarketing.dto.go | 15 +- .../repports/services/repport.service.go | 107 +++-- internal/utils/recording/util.recording.go | 76 +++- .../utils/recording/util.recording_test.go | 47 +++ 22 files changed, 1762 insertions(+), 328 deletions(-) create mode 100644 docs/farm_stock_attribution_design.md create mode 100644 internal/common/repository/marketing_delivery_attribution.repository.go create mode 100644 internal/common/repository/marketing_delivery_attribution.repository_test.go create mode 100644 internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.down.sql create mode 100644 internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql create mode 100644 internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go create mode 100644 internal/modules/marketing/repositories/salesorder_delivery_product.repository_test.go create mode 100644 internal/utils/recording/util.recording_test.go 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/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..27cd15af 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -298,10 +298,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 +341,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 +360,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 +381,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 +782,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) @@ -1453,6 +1464,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 +1491,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 +1575,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 +1637,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 +1657,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/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index e49fc421..87d114c9 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -84,31 +84,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) { @@ -266,18 +263,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 +288,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..a224404b --- /dev/null +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go @@ -0,0 +1,117 @@ +package repository + +import ( + "context" + "testing" + + "github.com/glebarez/sqlite" + "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) + } + } +} 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/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/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 7c4c9341..f5691b57 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -366,6 +366,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return nil, err } + depletionSourceIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint { + if d.SourceProductWarehouseId == nil { + return 0 + } + return *d.SourceProductWarehouseId + }) + if err := s.ensureProductWarehousesByFlags(ctx, depletionSourceIDs, []string{"DOC", "PULLET", "LAYER"}, "depletion source"); err != nil { + return nil, err + } eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { return nil, err @@ -435,11 +444,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 } } @@ -460,7 +474,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 @@ -590,13 +604,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 { @@ -613,6 +627,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 } @@ -630,11 +653,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 } } @@ -700,7 +728,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 @@ -1476,6 +1504,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 { @@ -2500,12 +2531,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/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/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/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) + } +} From 030284a9b541a6057d797a3744af3ad98011d36d Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Wed, 1 Apr 2026 11:03:35 +0700 Subject: [PATCH 4/7] codex/fix: purchase receivement error and recording doesn't show depletion/egg --- .../services/product_warehouse.service.go | 23 ++- .../product_warehouse.service_test.go | 111 +++++++++++++ .../product_warehouse.validation.go | 1 + .../repositories/purchase.repository.go | 27 +++- .../repositories/purchase.repository_test.go | 152 ++++++++++++++++++ 5 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 internal/modules/inventory/product-warehouses/services/product_warehouse.service_test.go create mode 100644 internal/modules/purchases/repositories/purchase.repository_test.go 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..15beaf58 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,24 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Chickins") } +func applyWarehouseSelectionFilter(db *gorm.DB, kandangID, locationID uint) *gorm.DB { + switch { + case kandangID != 0 && locationID != 0: + return db.Where( + "w_scope.location_id = ? AND (w_scope.type = ? OR w_scope.kandang_id = ?)", + locationID, + "LOKASI", + kandangID, + ) + case kandangID != 0: + return db.Where("w_scope.kandang_id = ?", kandangID) + case locationID != 0: + return db.Where("w_scope.location_id = ?", locationID) + default: + return db + } +} + func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -133,10 +151,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("product_id = ?", params.ProductId) } - if params.KandangId != 0 { - db = db.Joins("JOIN warehouses ON product_warehouses.warehouse_id = warehouses.id"). - Where("warehouses.kandang_id = ?", params.KandangId) - } + db = applyWarehouseSelectionFilter(db, params.KandangId, params.LocationId) if params.WarehouseId != 0 { db = db.Where("warehouse_id = ?", params.WarehouseId) 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..0dc954df --- /dev/null +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service_test.go @@ -0,0 +1,111 @@ +package service + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestApplyWarehouseSelectionFilterIncludesFarmAndSelectedKandangInLocation(t *testing.T) { + db := setupProductWarehouseServiceTestDB(t) + + var ids []uint + err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 101). + Order("product_warehouses.id"). + Pluck("product_warehouses.id", &ids).Error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertUintIDs(t, ids, []uint{1, 2}) +} + +func TestApplyWarehouseSelectionFilterPreservesKandangOnlyBehavior(t *testing.T) { + db := setupProductWarehouseServiceTestDB(t) + + var ids []uint + err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 0). + Order("product_warehouses.id"). + Pluck("product_warehouses.id", &ids).Error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertUintIDs(t, ids, []uint{1}) +} + +func TestApplyWarehouseSelectionFilterSupportsLocationOnlyQuery(t *testing.T) { + db := setupProductWarehouseServiceTestDB(t) + + var ids []uint + err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 0, 101). + Order("product_warehouses.id"). + Pluck("product_warehouses.id", &ids).Error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertUintIDs(t, ids, []uint{1, 2, 3}) +} + +func setupProductWarehouseServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + statements := []string{ + `CREATE TABLE warehouses ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL, + location_id INTEGER NULL, + kandang_id INTEGER NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE product_warehouses ( + id INTEGER PRIMARY KEY, + warehouse_id INTEGER NOT NULL + )`, + `INSERT INTO warehouses (id, type, location_id, kandang_id, deleted_at) VALUES + (1, 'KANDANG', 101, 11, NULL), + (2, 'LOKASI', 101, NULL, NULL), + (3, 'KANDANG', 101, 12, NULL), + (4, 'LOKASI', 102, NULL, NULL)`, + `INSERT INTO product_warehouses (id, warehouse_id) VALUES + (1, 1), + (2, 2), + (3, 3), + (4, 4)`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +} + +func baseProductWarehouseSelectionQuery(db *gorm.DB) *gorm.DB { + return db.Table("product_warehouses"). + Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.deleted_at IS NULL") +} + +func assertUintIDs(t *testing.T, got []uint, want []uint) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("expected ids %v, got %v", want, got) + } + + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected ids %v, got %v", want, got) + } + } +} 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..0e3ad15d 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -17,6 +17,7 @@ type Query struct { Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + LocationId uint `query:"location_id" validate:"omitempty,number,min=1"` Flags string `query:"flags" validate:"omitempty"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` 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 +} From c4add1501d09cd177c0146581e44e6d4d1f29b90 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Wed, 1 Apr 2026 11:46:44 +0700 Subject: [PATCH 5/7] codex/fix: recheck and fix purchase receive failed and farm stock not shown on recording --- .../product_warehouse.controller.go | 1 + .../product_warehouse.controller_test.go | 64 +++++++++++++++ .../product_warehouse.repository.go | 39 ++++++++- .../product_warehouse.repository_test.go | 73 +++++++++++++++++ .../services/fifo_stock_v2_helper.go | 46 ++++++++++- .../services/fifo_stock_v2_helper_test.go | 80 +++++++++++++++++++ internal/utils/constant.go | 68 +++++++++++++++- 7 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller_test.go create mode 100644 internal/modules/purchases/services/fifo_stock_v2_helper_test.go 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..5fd060dd 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -29,6 +29,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), + LocationId: uint(c.QueryInt("location_id", 0)), Flags: c.Query("flags", ""), KandangId: uint(c.QueryInt("kandang_id", 0)), TransferContext: c.Query(utils.TransferContextKey, ""), 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..93a015ca --- /dev/null +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller_test.go @@ -0,0 +1,64 @@ +package controller + +import ( + "errors" + "net/http/httptest" + "testing" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" + "gorm.io/gorm" +) + +type stubProductWarehouseService struct { + lastQuery *validation.Query +} + +func (s *stubProductWarehouseService) GetAll(_ *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { + s.lastQuery = params + return []entity.ProductWarehouse{}, 0, nil +} + +func (s *stubProductWarehouseService) GetOne(_ *fiber.Ctx, _ uint) (*entity.ProductWarehouse, error) { + return nil, gorm.ErrRecordNotFound +} + +var _ service.ProductWarehouseService = (*stubProductWarehouseService)(nil) + +func TestGetAllParsesLocationID(t *testing.T) { + app := fiber.New() + stub := &stubProductWarehouseService{} + ctrl := NewProductWarehouseController(stub) + app.Get("/product-warehouses", ctrl.GetAll) + + req := httptest.NewRequest("GET", "/product-warehouses?location_id=16&kandang_id=59&limit=25", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + if stub.lastQuery == nil { + t.Fatalf("expected service to receive query") + } + if stub.lastQuery.LocationId != 16 { + t.Fatalf("expected location_id 16, got %d", stub.lastQuery.LocationId) + } + if stub.lastQuery.KandangId != 59 { + t.Fatalf("expected kandang_id 59, got %d", stub.lastQuery.KandangId) + } + if stub.lastQuery.Limit != 25 { + t.Fatalf("expected limit 25, got %d", stub.lastQuery.Limit) + } +} + +func TestStubImplementsServiceContract(t *testing.T) { + validate := validator.New() + if validate == nil { + t.Fatal(errors.New("validator should not be nil")) + } +} 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 87d114c9..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" ) @@ -164,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() } 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 index a224404b..3f810e53 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/glebarez/sqlite" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" ) @@ -115,3 +116,75 @@ func insertProductWarehouseTestFixtures(t *testing.T, db *gorm.DB) { } } } + +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/purchases/services/fifo_stock_v2_helper.go b/internal/modules/purchases/services/fifo_stock_v2_helper.go index e0b619a9..da7770cc 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" ) @@ -76,11 +78,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..dbc29110 --- /dev/null +++ b/internal/modules/purchases/services/fifo_stock_v2_helper_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "context" + "testing" + + "github.com/glebarez/sqlite" + "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 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/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 { From 5ffb72507bf5714eaa5335f2af3de686d4e1a6a6 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Wed, 1 Apr 2026 12:31:04 +0700 Subject: [PATCH 6/7] codex/fix: show farm stock usage on closing page --- .../repositories/closing.repository.go | 356 +++++++++++++++++- .../repositories/closing.repository_test.go | 208 ++++++++++ .../closings/services/closing.service.go | 6 +- .../closings/services/sapronak.service.go | 4 +- 4 files changed, 549 insertions(+), 25 deletions(-) create mode 100644 internal/modules/closings/repositories/closing.repository_test.go diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 27cd15af..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) @@ -855,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) @@ -862,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) } @@ -1132,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, @@ -1158,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 { 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 } From 7b4bf94329e057a2a6c0e16f76f5f45585640c51 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Wed, 1 Apr 2026 16:14:07 +0700 Subject: [PATCH 7/7] codex/fix: qty 0 after PO to farm-level warehouse --- docs/qa_farm_stock_test_cases.csv | 45 +++++++++++++ docs/qa_farm_stock_test_cases.xlsx | Bin 0 -> 15601 bytes .../services/fifo_stock_v2_helper.go | 11 +++ .../services/fifo_stock_v2_helper_test.go | 63 ++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 docs/qa_farm_stock_test_cases.csv create mode 100644 docs/qa_farm_stock_test_cases.xlsx 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 0000000000000000000000000000000000000000..437e962c32b5f4e1d56956d21d7fc1f78a06127f GIT binary patch literal 15601 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