diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index 74195f95..ccba7120 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -10,8 +11,61 @@ import ( "gorm.io/gorm" ) +type HppV2ProjectFlockKandangContext struct { + ProjectFlockKandangID uint + ProjectFlockID uint + ProjectFlockCategory string + KandangID uint + KandangName string + LocationID uint + HouseType string +} + +type HppV2UsageCostRow struct { + StockableType string + StockableID uint + SourceProductID uint + SourceProductName string + Qty float64 + UnitPrice float64 + TotalCost float64 + FirstUsedAt time.Time + LastUsedAt time.Time +} + +type HppV2AdjustmentCostRow struct { + AdjustmentID uint + ProjectFlockKandangID *uint + ProductWarehouseID uint + ProductID uint + ProductName string + WarehouseID uint + WarehouseType string + Qty float64 + Price float64 + GrandTotal float64 + CreatedAt time.Time +} + +type HppV2ExpenseCostRow struct { + ExpenseRealizationID uint + ExpenseNonstockID uint + ExpenseID uint + NonstockID uint + NonstockName string + Qty float64 + Price float64 + TotalCost float64 + RealizationDate time.Time +} + type HppV2CostRepository interface { + GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) + ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error) + ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error) + ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) + ListExpenseRealizationRowsByProjectFlockID(ctx context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) @@ -27,6 +81,33 @@ func NewHppV2CostRepository(db *gorm.DB) HppV2CostRepository { return &HppV2RepositoryImpl{db: db} } +func (r *HppV2RepositoryImpl) GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) { + var row HppV2ProjectFlockKandangContext + err := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + pfk.id AS project_flock_kandang_id, + pf.id AS project_flock_id, + pf.category AS project_flock_category, + k.id AS kandang_id, + k.name AS kandang_name, + k.location_id AS location_id, + k.house_type::text AS house_type + `). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pfk.id = ?", projectFlockKandangId). + Scan(&row).Error + if err != nil { + return nil, err + } + if row.ProjectFlockKandangID == 0 { + return nil, gorm.ErrRecordNotFound + } + + return &row, nil +} + func (r *HppV2RepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) { var ids []uint err := r.db.WithContext(ctx). @@ -41,6 +122,241 @@ func (r *HppV2RepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, pro return ids, nil } +func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, + date *time.Time, +) ([]HppV2UsageCostRow, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return []HppV2UsageCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableRecordingStock := fifo.UsableKeyRecordingStock.String() + + rows := make([]HppV2UsageCostRow, 0) + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + sa.stockable_type AS stockable_type, + sa.stockable_id AS stockable_id, + COALESCE(pi.product_id, ast_pw.product_id, 0) AS source_product_id, + COALESCE(pi_prod.name, ast_prod.name, '') AS source_product_name, + COALESCE(SUM(sa.qty), 0) AS qty, + CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END AS unit_price, + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0) AS total_cost, + MIN(r.record_datetime) AS first_used_at, + MAX(r.record_datetime) AS last_used_at + `, + stockablePurchase, + stockableAdjustment, + stockablePurchase, + stockableAdjustment, + ). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableRecordingStock, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS pi_prod ON pi_prod.id = pi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id"). + Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id"). + Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames). + Group(` + sa.stockable_type, + sa.stockable_id, + COALESCE(pi.product_id, ast_pw.product_id, 0), + COALESCE(pi_prod.name, ast_prod.name, ''), + CASE + WHEN sa.stockable_type = '` + stockablePurchase + `' THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = '` + stockableAdjustment + `' THEN COALESCE(ast.price, 0) + ELSE 0 + END + `). + Order("MIN(r.record_datetime) ASC, sa.stockable_type ASC, sa.stockable_id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, + date *time.Time, +) ([]HppV2AdjustmentCostRow, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return []HppV2AdjustmentCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + rows := make([]HppV2AdjustmentCostRow, 0) + err := r.db.WithContext(ctx). + Table("adjustment_stocks AS ast"). + Select(` + ast.id AS adjustment_id, + pw.project_flock_kandang_id AS project_flock_kandang_id, + ast.product_warehouse_id AS product_warehouse_id, + pw.product_id AS product_id, + p.name AS product_name, + w.id AS warehouse_id, + w.type AS warehouse_type, + COALESCE(ast.total_qty, 0) AS qty, + COALESCE(ast.price, 0) AS price, + COALESCE(ast.grand_total, 0) AS grand_total, + ast.created_at AS created_at + `). + Joins("JOIN product_warehouses AS pw ON pw.id = ast.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN warehouses AS w ON w.id = pw.warehouse_id"). + Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("ast.created_at <= ?", *date). + Where("COALESCE(ast.total_qty, 0) > 0"). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames). + Order("ast.created_at ASC, ast.id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListExpenseRealizationRowsByProjectFlockKandangIDs( + ctx context.Context, + projectFlockKandangIDs []uint, + date *time.Time, + ekspedisi bool, +) ([]HppV2ExpenseCostRow, error) { + if len(projectFlockKandangIDs) == 0 { + return []HppV2ExpenseCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + rows := make([]HppV2ExpenseCostRow, 0) + query := r.db.WithContext(ctx). + Table("expense_realizations AS er"). + Select(` + er.id AS expense_realization_id, + en.id AS expense_nonstock_id, + e.id AS expense_id, + COALESCE(n.id, 0) AS nonstock_id, + COALESCE(n.name, '') AS nonstock_name, + COALESCE(er.qty, 0) AS qty, + COALESCE(er.price, 0) AS price, + COALESCE(er.qty, 0) * COALESCE(er.price, 0) AS total_cost, + COALESCE(e.realization_date, DATE(er.created_at)) AS realization_date + `). + Joins("JOIN expense_nonstocks AS en ON en.id = er.expense_nonstock_id"). + Joins("JOIN expenses AS e ON e.id = en.expense_id"). + Joins("LEFT JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ? AND f.name = ?", entity.FlagableTypeNonstock, utils.FlagEkspedisi). + Where("e.deleted_at IS NULL"). + Where("e.category = ?", utils.ExpenseCategoryBOP). + Where("en.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("COALESCE(e.realization_date, DATE(er.created_at)) <= ?", *date) + + if ekspedisi { + query = query.Where("f.id IS NOT NULL") + } else { + query = query.Where("f.id IS NULL") + } + + if err := query. + Order("COALESCE(e.realization_date, DATE(er.created_at)) ASC, er.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListExpenseRealizationRowsByProjectFlockID( + ctx context.Context, + projectFlockID uint, + date *time.Time, + ekspedisi bool, +) ([]HppV2ExpenseCostRow, error) { + if projectFlockID == 0 { + return []HppV2ExpenseCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + rows := make([]HppV2ExpenseCostRow, 0) + query := r.db.WithContext(ctx). + Table("expense_realizations AS er"). + Select(` + er.id AS expense_realization_id, + en.id AS expense_nonstock_id, + e.id AS expense_id, + COALESCE(n.id, 0) AS nonstock_id, + COALESCE(n.name, '') AS nonstock_name, + COALESCE(er.qty, 0) AS qty, + COALESCE(er.price, 0) AS price, + COALESCE(er.qty, 0) * COALESCE(er.price, 0) AS total_cost, + COALESCE(e.realization_date, DATE(er.created_at)) AS realization_date + `). + Joins("JOIN expense_nonstocks AS en ON en.id = er.expense_nonstock_id"). + Joins("JOIN expenses AS e ON e.id = en.expense_id"). + Joins("LEFT JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ? AND f.name = ?", entity.FlagableTypeNonstock, utils.FlagEkspedisi). + Where("e.deleted_at IS NULL"). + Where("e.category = ?", utils.ExpenseCategoryBOP). + Where("en.project_flock_kandang_id IS NULL"). + Where("e.project_flock_id IS NOT NULL"). + Where("e.project_flock_id::jsonb @> ?::jsonb", fmt.Sprintf("[%d]", projectFlockID)). + Where("COALESCE(e.realization_date, DATE(er.created_at)) <= ?", *date) + + if ekspedisi { + query = query.Where("f.id IS NOT NULL") + } else { + query = query.Where("f.id IS NULL") + } + + if err := query. + Order("COALESCE(e.realization_date, DATE(er.created_at)) ASC, er.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + func (r *HppV2RepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) { if date == nil { now := time.Now() @@ -122,10 +438,13 @@ func (r *HppV2RepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKanda return 0, 0, err } - var adjustmentTotalWeight float64 + var adjustmentTotals struct { + TotalQty float64 + TotalWeight float64 + } adjustmentSubQuery := r.db.WithContext(ctx). Table("recordings AS r"). - Select("DISTINCT ast.id AS adjustment_id, ast.price AS price"). + Select("DISTINCT ast.id AS adjustment_id, ast.total_qty AS total_qty, ast.price AS price"). Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id"). Joins( @@ -141,13 +460,14 @@ func (r *HppV2RepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKanda err = r.db.WithContext(ctx). Table("(?) AS adjustment_sources", adjustmentSubQuery). - Select("COALESCE(SUM(adjustment_sources.price), 0)"). - Scan(&adjustmentTotalWeight).Error + Select("COALESCE(SUM(adjustment_sources.total_qty), 0) AS total_qty, COALESCE(SUM(adjustment_sources.price), 0) AS total_weight"). + Scan(&adjustmentTotals).Error if err != nil { return 0, 0, err } - totals.TotalWeightKg += adjustmentTotalWeight + totals.TotalPieces += adjustmentTotals.TotalQty + totals.TotalWeightKg += adjustmentTotals.TotalWeight return totals.TotalPieces, totals.TotalWeightKg, nil } @@ -200,7 +520,7 @@ sales_kandang AS ( JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN warehouses w ON w.id = pw.warehouse_id WHERE mdp.delivery_date IS NOT NULL - AND mdp.delivery_date <= ? + AND mdp.delivery_date <= ? AND UPPER(COALESCE(w.type, '')) = 'KANDANG' AND pw.project_flock_kandang_id IN (SELECT id FROM selected_pfk) AND EXISTS ( @@ -234,7 +554,7 @@ sales_lokasi AS ( JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN warehouses w ON w.id = pw.warehouse_id WHERE mdp.delivery_date IS NOT NULL - AND mdp.delivery_date <= ? + AND mdp.delivery_date <= ? AND UPPER(COALESCE(w.type, '')) = 'LOKASI' AND w.location_id IN (SELECT location_id FROM selected_locations) AND EXISTS ( @@ -390,10 +710,10 @@ CROSS JOIN lokasi_rec_totals lrt Raw( query, projectFlockKandangIDs, - *startDate, + *endDate, entity.FlagableTypeProduct, eggFlags, - *startDate, + *endDate, entity.FlagableTypeProduct, eggFlags, string(utils.AdjustmentTransactionSubtypeRecordingEggIn), diff --git a/internal/common/repository/common.hppv2.repository_test.go b/internal/common/repository/common.hppv2.repository_test.go new file mode 100644 index 00000000..7aefad44 --- /dev/null +++ b/internal/common/repository/common.hppv2.repository_test.go @@ -0,0 +1,248 @@ +package repository + +import ( + "context" + "math" + "testing" + "time" + + "github.com/glebarez/sqlite" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +func TestHppV2RepositoryGetEggProduksiIncludesTransferredAdjustmentStock(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime) VALUES (1, 101, '2026-04-19 10:00:00')`, + `INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 401, 80, 8, 101)`, + `INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00')`, + `INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 301, 401)`, + `INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose) VALUES (1, 'STOCK_TRANSFER_OUT', 1, 'ADJUSTMENT_IN', 501, 'ACTIVE', 'CONSUME')`, + `INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, price, created_at) VALUES (501, 301, 20, 2.5, '2026-04-18 07:30:00')`, + ) + + repo := &HppV2RepositoryImpl{db: db} + endDate := mustJakartaTime(t, "2026-04-20 00:00:00") + + totalPieces, totalWeightKg, err := repo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &endDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertFloatEquals(t, totalPieces, 100) + assertFloatEquals(t, totalWeightKg, 10.5) +} + +func TestHppV2RepositoryGetEggTerjualUsesEndDateForSameDayFarmSales(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`, + `INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`, + `INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10)`, + `INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL)`, + `INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`, + `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES (1, 101, '2026-04-19 08:00:00', NULL), (2, 102, '2026-04-19 09:00:00', NULL)`, + `INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 301, 60, 6, 101), (2, 2, 301, 40, 4, 102)`, + `INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`, + `INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 50, 5, '2026-04-19 12:00:00')`, + ) + + repo := &HppV2RepositoryImpl{db: db} + startDate := mustJakartaTime(t, "2026-04-19 00:00:00") + endDate := mustJakartaTime(t, "2026-04-20 00:00:00") + + totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertFloatEquals(t, totalPieces, 30) + assertFloatEquals(t, totalWeightKg, 3) +} + +func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`, + `INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`, + `INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10), (211, 'KANDANG', 10), (212, 'KANDANG', 10)`, + `INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL), (311, 211, 900, 101), (312, 212, 900, 102)`, + `INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`, + `INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00'), (2, '2026-04-18 08:15:00')`, + `INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 311, 301), (2, 2, 312, 301)`, + `INSERT INTO adjustment_stocks (id, product_warehouse_id, usage_qty, price, function_code, transaction_type, created_at) VALUES + (801, 311, 70, 7, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:00:00'), + (802, 312, 30, 3, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:30:00')`, + `INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`, + `INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 20, 2, '2026-04-19 12:00:00')`, + ) + + repo := &HppV2RepositoryImpl{db: db} + startDate := mustJakartaTime(t, "2026-04-19 00:00:00") + endDate := mustJakartaTime(t, "2026-04-20 00:00:00") + + totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertFloatEquals(t, totalPieces, 14) + assertFloatEquals(t, totalWeightKg, 1.4) +} + +func setupHppV2RepositoryTestDB(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) + } + + mustExecHppV2(t, db, + `CREATE TABLE recordings ( + id INTEGER PRIMARY KEY, + project_flock_kandangs_id INTEGER NULL, + record_datetime DATETIME NULL, + deleted_at DATETIME NULL + )`, + `CREATE TABLE recording_eggs ( + id INTEGER PRIMARY KEY, + recording_id INTEGER NULL, + product_warehouse_id INTEGER NULL, + qty NUMERIC(15,3) NULL, + weight NUMERIC(15,3) NULL, + project_flock_kandang_id INTEGER NULL + )`, + `CREATE TABLE stock_transfers ( + id INTEGER PRIMARY KEY, + transfer_date DATETIME NULL + )`, + `CREATE TABLE stock_transfer_details ( + id INTEGER PRIMARY KEY, + stock_transfer_id INTEGER NULL, + source_product_warehouse_id INTEGER NULL, + dest_product_warehouse_id INTEGER NULL + )`, + `CREATE TABLE stock_allocations ( + id INTEGER PRIMARY KEY, + usable_type TEXT NULL, + usable_id INTEGER NULL, + stockable_type TEXT NULL, + stockable_id INTEGER NULL, + status TEXT NULL, + allocation_purpose TEXT NULL, + qty NUMERIC(15,3) NULL + )`, + `CREATE TABLE adjustment_stocks ( + id INTEGER PRIMARY KEY, + product_warehouse_id INTEGER NULL, + total_qty NUMERIC(15,3) NULL, + usage_qty NUMERIC(15,3) NULL, + price NUMERIC(15,3) NULL, + grand_total NUMERIC(15,3) NULL, + function_code TEXT NULL, + transaction_type TEXT NULL, + created_at DATETIME NULL + )`, + `CREATE TABLE kandangs ( + id INTEGER PRIMARY KEY, + location_id INTEGER NULL + )`, + `CREATE TABLE project_flock_kandangs ( + id INTEGER PRIMARY KEY, + kandang_id INTEGER NULL, + project_flock_id INTEGER NULL + )`, + `CREATE TABLE warehouses ( + id INTEGER PRIMARY KEY, + type TEXT NULL, + location_id INTEGER NULL + )`, + `CREATE TABLE product_warehouses ( + id INTEGER PRIMARY KEY, + warehouse_id INTEGER NULL, + product_id INTEGER NULL, + project_flock_kandang_id INTEGER NULL + )`, + `CREATE TABLE marketing_products ( + id INTEGER PRIMARY KEY, + product_warehouse_id INTEGER NULL + )`, + `CREATE TABLE marketing_delivery_products ( + id INTEGER PRIMARY KEY, + marketing_product_id INTEGER NULL, + usage_qty NUMERIC(15,3) NULL, + total_weight NUMERIC(15,3) NULL, + delivery_date DATETIME NULL + )`, + `CREATE TABLE flags ( + id INTEGER PRIMARY KEY, + flagable_type TEXT NULL, + flagable_id INTEGER NULL, + name TEXT NULL + )`, + ) + + return db +} + +func mustExecHppV2(t *testing.T, db *gorm.DB, statements ...string) { + t.Helper() + + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("failed executing statement %q: %v", statement, err) + } + } +} + +func mustJakartaTime(t *testing.T, raw string) time.Time { + t.Helper() + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading timezone: %v", err) + } + + value, err := time.ParseInLocation("2006-01-02 15:04:05", raw, location) + if err != nil { + t.Fatalf("failed parsing time %q: %v", raw, err) + } + + return value +} + +func assertFloatEquals(t *testing.T, got float64, want float64) { + t.Helper() + + if math.Abs(got-want) > 0.000001 { + t.Fatalf("expected %.6f, got %.6f", want, got) + } +} + +func TestHppV2RepositoryConstantsStayAlignedWithProductionQueries(t *testing.T) { + if fifo.UsableKeyStockTransferOut.String() != "STOCK_TRANSFER_OUT" { + t.Fatalf("unexpected stock transfer usable key: %s", fifo.UsableKeyStockTransferOut.String()) + } + if fifo.StockableKeyAdjustmentIn.String() != "ADJUSTMENT_IN" { + t.Fatalf("unexpected adjustment stockable key: %s", fifo.StockableKeyAdjustmentIn.String()) + } + if entity.StockAllocationStatusActive != "ACTIVE" { + t.Fatalf("unexpected active stock allocation status: %s", entity.StockAllocationStatusActive) + } + if entity.StockAllocationPurposeConsume != "CONSUME" { + t.Fatalf("unexpected consume stock allocation purpose: %s", entity.StockAllocationPurposeConsume) + } + if string(utils.AdjustmentTransactionSubtypeRecordingEggIn) != "RECORDING_EGG_IN" { + t.Fatalf("unexpected adjustment function code: %s", utils.AdjustmentTransactionSubtypeRecordingEggIn) + } + if string(utils.AdjustmentTransactionTypeRecording) != "RECORDING" { + t.Fatalf("unexpected adjustment transaction type: %s", utils.AdjustmentTransactionTypeRecording) + } +} diff --git a/internal/common/service/common.hppv2.model.go b/internal/common/service/common.hppv2.model.go new file mode 100644 index 00000000..134ecb81 --- /dev/null +++ b/internal/common/service/common.hppv2.model.go @@ -0,0 +1,56 @@ +package service + +type HppV2DateWindow struct { + Start string `json:"start"` + End string `json:"end"` +} + +type HppV2Proration struct { + Basis string `json:"basis"` + Numerator float64 `json:"numerator"` + Denominator float64 `json:"denominator"` + Ratio float64 `json:"ratio"` +} + +type HppV2Reference struct { + Type string `json:"type"` + ID uint `json:"id"` + StockableType string `json:"stockable_type,omitempty"` + ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"` + ProductID uint `json:"product_id,omitempty"` + ProductName string `json:"product_name,omitempty"` + Date string `json:"date,omitempty"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + Total float64 `json:"total"` + AppliedTotal float64 `json:"applied_total"` +} + +type HppV2ComponentPart struct { + Code string `json:"code"` + Title string `json:"title"` + Total float64 `json:"total"` + Proration *HppV2Proration `json:"proration,omitempty"` + References []HppV2Reference `json:"references,omitempty"` +} + +type HppV2Component struct { + Code string `json:"code"` + Title string `json:"title"` + Total float64 `json:"total"` + Parts []HppV2ComponentPart `json:"parts"` +} + +type HppV2Breakdown struct { + ProjectFlockKandangID uint `json:"project_flock_kandang_id"` + ProjectFlockID uint `json:"project_flock_id"` + ProjectFlockCategory string `json:"project_flock_category,omitempty"` + KandangID uint `json:"kandang_id,omitempty"` + KandangName string `json:"kandang_name,omitempty"` + LocationID uint `json:"location_id,omitempty"` + PeriodDate string `json:"period_date"` + Window HppV2DateWindow `json:"window"` + TotalProductionCost float64 `json:"total_production_cost"` + Components []HppV2Component `json:"components"` + Hpp HppCostResponse `json:"hpp"` +} diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index ea7375df..0bde1dfa 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -5,13 +5,40 @@ import ( "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +const ( + hppV2ComponentPakan = "PAKAN" + hppV2ComponentOvk = "OVK" + hppV2ComponentBopRegular = "BOP_REGULAR" + hppV2ComponentBopEksp = "BOP_EKSPEDISI" + hppV2PartGrowingNormal = "growing_normal" + hppV2PartGrowingCutover = "growing_cutover" + hppV2PartLayingNormal = "laying_normal" + hppV2PartLayingCutover = "laying_cutover" + hppV2PartGrowingDirect = "growing_direct" + hppV2PartGrowingFarm = "growing_farm" + hppV2PartLayingDirect = "laying_direct" + hppV2PartLayingFarm = "laying_farm" + hppV2ProrationPopulation = "growing_population_share" + hppV2ProrationEggWeight = "laying_egg_weight_share" + hppV2ProrationEggPiece = "laying_egg_piece_share" + hppV2CutoverFlagPakan = "PAKAN-CUTOVER" + hppV2CutoverFlagOvk = "OVK-CUTOVER" ) type HppV2Service interface { CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) + CalculateHppBreakdown(projectFlockKandangId uint, date *time.Time) (*HppV2Breakdown, error) GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) - GetFeedGrowing(projectFlockKandangId uint, endDate *time.Time) (float64, error) - GetFeedLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostBopEkspedisi(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetPakanBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetOvkBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) } @@ -19,101 +46,676 @@ type hppV2Service struct { hppRepo commonRepo.HppV2CostRepository } +type hppV2StockComponentConfig struct { + Code string + Title string + NormalFlags []string + CutoverFlags []string +} + +type hppV2ExpenseComponentConfig struct { + Code string + Title string + Ekspedisi bool +} + func NewHppV2Service(hppRepo commonRepo.HppV2CostRepository) HppV2Service { return &hppV2Service{hppRepo: hppRepo} } func (s *hppV2Service) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) { - if date == nil { - now := time.Now() - date = &now + breakdown, err := s.CalculateHppBreakdown(projectFlockKandangId, date) + if err != nil { + return nil, err + } + if breakdown == nil { + return &HppCostResponse{}, nil } - location, err := time.LoadLocation("Asia/Jakarta") + result := breakdown.Hpp + return &result, nil +} + +func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *time.Time) (*HppV2Breakdown, error) { + if s.hppRepo == nil { + return &HppV2Breakdown{ + ProjectFlockKandangID: projectFlockKandangId, + Hpp: HppCostResponse{}, + }, nil + } + + startOfDay, endOfDay, err := hppV2DayWindow(date) if err != nil { return nil, err } - startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) - endOfDay := startOfDay.Add(24 * time.Hour) - - pakan, err := s.GetCostPakan(projectFlockKandangId, &endOfDay) + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) if err != nil { return nil, err } - result, err := s.GetHppEstimationDanRealisasi(pakan, projectFlockKandangId, &startOfDay, &endOfDay) + pakanComponent, err := s.GetPakanBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - return result, nil + totalProductionCost := 0.0 + components := make([]HppV2Component, 0, 4) + if pakanComponent != nil && (pakanComponent.Total > 0 || len(pakanComponent.Parts) > 0) { + totalProductionCost += pakanComponent.Total + components = append(components, *pakanComponent) + } + + ovkComponent, err := s.GetOvkBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + if ovkComponent != nil && (ovkComponent.Total > 0 || len(ovkComponent.Parts) > 0) { + totalProductionCost += ovkComponent.Total + components = append(components, *ovkComponent) + } + + bopRegularComponent, err := s.GetBopRegularBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + if bopRegularComponent != nil && (bopRegularComponent.Total > 0 || len(bopRegularComponent.Parts) > 0) { + totalProductionCost += bopRegularComponent.Total + components = append(components, *bopRegularComponent) + } + + bopEkspedisiComponent, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + if bopEkspedisiComponent != nil && (bopEkspedisiComponent.Total > 0 || len(bopEkspedisiComponent.Parts) > 0) { + totalProductionCost += bopEkspedisiComponent.Total + components = append(components, *bopEkspedisiComponent) + } + + hppCost, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) + if err != nil { + return nil, err + } + if hppCost == nil { + hppCost = &HppCostResponse{} + } + + return &HppV2Breakdown{ + ProjectFlockKandangID: projectFlockKandangId, + ProjectFlockID: contextRow.ProjectFlockID, + ProjectFlockCategory: contextRow.ProjectFlockCategory, + KandangID: contextRow.KandangID, + KandangName: contextRow.KandangName, + LocationID: contextRow.LocationID, + PeriodDate: startOfDay.Format("2006-01-02"), + Window: HppV2DateWindow{ + Start: startOfDay.Format(time.RFC3339), + End: endOfDay.Format(time.RFC3339), + }, + TotalProductionCost: totalProductionCost, + Components: components, + Hpp: *hppCost, + }, nil } func (s *hppV2Service) GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - feedGrowing, err := s.GetFeedGrowing(projectFlockKandangId, endDate) + component, err := s.GetPakanBreakdown(projectFlockKandangId, endDate) if err != nil { return 0, err } - - feedLaying, err := s.GetFeedLaying(projectFlockKandangId, endDate) - if err != nil { - return 0, err + if component == nil { + return 0, nil } - pakan := feedGrowing + feedLaying - return pakan, nil + return component.Total, nil } -func (s *hppV2Service) GetFeedGrowing(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - if s.hppRepo == nil { +func (s *hppV2Service) GetPakanBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getStockUsageComponent(projectFlockKandangId, endDate, hppV2StockComponentConfig{ + Code: hppV2ComponentPakan, + Title: "Pakan", + NormalFlags: []string{string(utils.FlagPakan)}, + CutoverFlags: []string{hppV2CutoverFlagPakan}, + }) +} + +func (s *hppV2Service) GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetOvkBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { return 0, nil } + return component.Total, nil +} + +func (s *hppV2Service) GetOvkBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getStockUsageComponent(projectFlockKandangId, endDate, hppV2StockComponentConfig{ + Code: hppV2ComponentOvk, + Title: "OVK", + NormalFlags: []string{ + string(utils.FlagOVK), + string(utils.FlagObat), + string(utils.FlagVitamin), + string(utils.FlagKimia), + }, + CutoverFlags: []string{hppV2CutoverFlagOvk}, + }) +} + +func (s *hppV2Service) GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetBopRegularBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetCostBopEkspedisi(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getExpenseComponent(projectFlockKandangId, endDate, hppV2ExpenseComponentConfig{ + Code: hppV2ComponentBopRegular, + Title: "BOP Regular", + Ekspedisi: false, + }) +} + +func (s *hppV2Service) GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getExpenseComponent(projectFlockKandangId, endDate, hppV2ExpenseComponentConfig{ + Code: hppV2ComponentBopEksp, + Title: "BOP Ekspedisi", + Ekspedisi: true, + }) +} + +func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2StockComponentConfig) (*HppV2Component, error) { + if s.hppRepo == nil { + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Parts: []HppV2ComponentPart{}, + }, nil + } + + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 4) + total := 0.0 + + growingPart, err := s.buildGrowingUsagePart(projectFlockKandangId, contextRow, endDate, config, false) + if err != nil { + return nil, err + } + if growingPart != nil { + parts = append(parts, *growingPart) + total += growingPart.Total + } + + growingCutoverPart, err := s.buildGrowingUsagePart(projectFlockKandangId, contextRow, endDate, config, true) + if err != nil { + return nil, err + } + if growingCutoverPart != nil { + parts = append(parts, *growingCutoverPart) + total += growingCutoverPart.Total + } + + layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, false) + if err != nil { + return nil, err + } + if layingNormalPart != nil { + parts = append(parts, *layingNormalPart) + total += layingNormalPart.Total + } + + layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, true) + if err != nil { + return nil, err + } + if layingCutoverPart != nil { + parts = append(parts, *layingCutoverPart) + total += layingCutoverPart.Total + } + + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Total: total, + Parts: parts, + }, nil +} + +func (s *hppV2Service) getExpenseComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2ExpenseComponentConfig) (*HppV2Component, error) { + if s.hppRepo == nil { + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Parts: []HppV2ComponentPart{}, + }, nil + } + + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 4) + total := 0.0 + + growingDirect, err := s.buildGrowingExpenseDirectPart(projectFlockKandangId, contextRow, endDate, config) + if err != nil { + return nil, err + } + if growingDirect != nil { + parts = append(parts, *growingDirect) + total += growingDirect.Total + } + + growingFarm, err := s.buildGrowingExpenseFarmPart(projectFlockKandangId, contextRow, endDate, config) + if err != nil { + return nil, err + } + if growingFarm != nil { + parts = append(parts, *growingFarm) + total += growingFarm.Total + } + + layingDirect, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, endDate, config) + if err != nil { + return nil, err + } + if layingDirect != nil { + parts = append(parts, *layingDirect) + total += layingDirect.Total + } + + layingFarm, err := s.buildLayingExpenseFarmPart(projectFlockKandangId, contextRow, endDate, config) + if err != nil { + return nil, err + } + if layingFarm != nil { + parts = append(parts, *layingFarm) + total += layingFarm.Total + } + + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Total: total, + Parts: parts, + }, nil +} + +func (s *hppV2Service) buildGrowingUsagePart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2StockComponentConfig, + cutover bool, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) if err != nil { - return 0, err + return nil, err } if sourceProjectFlockID == 0 || transferTotalQty <= 0 { - return 0, nil + return nil, nil } kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) if err != nil { - return 0, err + return nil, err } if len(kandangIDsGrowing) == 0 { - return 0, nil - } - - feedUsageCostGrowing, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDsGrowing, endDate) - if err != nil { - return 0, err + return nil, nil } totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) if err != nil { - return 0, err + return nil, err } if totalPopulationFlockGrowing == 0 { - return 0, nil + return nil, nil } - result := feedUsageCostGrowing * (transferTotalQty / totalPopulationFlockGrowing) - return result, nil + ratio := transferTotalQty / totalPopulationFlockGrowing + if ratio <= 0 { + return nil, nil + } + + partCode := hppV2PartGrowingNormal + partTitle := "Growing" + baseRows := make([]HppV2Reference, 0) + baseTotal := 0.0 + + if cutover { + partCode = hppV2PartGrowingCutover + partTitle = "Growing Cut-over" + + rows, err := s.hppRepo.ListAdjustmentCostRowsByProductFlags(context.Background(), kandangIDsGrowing, config.CutoverFlags, endDate) + if err != nil { + return nil, err + } + for _, row := range rows { + rowTotal := adjustmentRowTotalCost(row) + baseTotal += rowTotal + baseRows = append(baseRows, HppV2Reference{ + Type: "adjustment_stock", + ID: row.AdjustmentID, + ProjectFlockKandangID: row.ProjectFlockKandangID, + ProductID: row.ProductID, + ProductName: row.ProductName, + Date: row.CreatedAt.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.Price, + Total: rowTotal, + AppliedTotal: rowTotal * ratio, + }) + } + } else { + rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), kandangIDsGrowing, config.NormalFlags, endDate) + if err != nil { + return nil, err + } + for _, row := range rows { + baseTotal += row.TotalCost + refDate := row.LastUsedAt + if refDate.IsZero() { + refDate = row.FirstUsedAt + } + baseRows = append(baseRows, HppV2Reference{ + Type: "stock_allocation", + ID: row.StockableID, + StockableType: row.StockableType, + ProductID: row.SourceProductID, + ProductName: row.SourceProductName, + Date: refDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.UnitPrice, + Total: row.TotalCost, + AppliedTotal: row.TotalCost * ratio, + }) + } + } + + if baseTotal == 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: partCode, + Title: partTitle, + Total: baseTotal * ratio, + Proration: &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: transferTotalQty, + Denominator: totalPopulationFlockGrowing, + Ratio: ratio, + }, + References: baseRows, + }, nil } -func (s *hppV2Service) GetFeedLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - if s.hppRepo == nil { - return 0, nil +func (s *hppV2Service) buildLayingUsagePart( + projectFlockKandangId uint, + endDate *time.Time, + config hppV2StockComponentConfig, + cutover bool, +) (*HppV2ComponentPart, error) { + if cutover { + rows, err := s.hppRepo.ListAdjustmentCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.CutoverFlags, endDate) + if err != nil { + return nil, err + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + rowTotal := adjustmentRowTotalCost(row) + total += rowTotal + references = append(references, HppV2Reference{ + Type: "adjustment_stock", + ID: row.AdjustmentID, + ProjectFlockKandangID: row.ProjectFlockKandangID, + ProductID: row.ProductID, + ProductName: row.ProductName, + Date: row.CreatedAt.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.Price, + Total: rowTotal, + AppliedTotal: rowTotal, + }) + } + if total == 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartLayingCutover, + Title: "Laying Cut-over", + Total: total, + References: references, + }, nil } - result, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) + rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate) if err != nil { - return 0, err + return nil, err } - return result, nil + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + total += row.TotalCost + refDate := row.LastUsedAt + if refDate.IsZero() { + refDate = row.FirstUsedAt + } + references = append(references, HppV2Reference{ + Type: "stock_allocation", + ID: row.StockableID, + StockableType: row.StockableType, + ProductID: row.SourceProductID, + ProductName: row.SourceProductName, + Date: refDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.UnitPrice, + Total: row.TotalCost, + AppliedTotal: row.TotalCost, + }) + } + if total == 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartLayingNormal, + Title: "Laying", + Total: total, + References: references, + }, nil +} + +func (s *hppV2Service) buildGrowingExpenseDirectPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + return s.buildGrowingExpensePart(projectFlockKandangId, contextRow, endDate, config, false) +} + +func (s *hppV2Service) buildGrowingExpenseFarmPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + return s.buildGrowingExpensePart(projectFlockKandangId, contextRow, endDate, config, true) +} + +func (s *hppV2Service) buildGrowingExpensePart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, + farmLevel bool, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + if sourceProjectFlockID == 0 || transferTotalQty <= 0 { + return nil, nil + } + + kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) + if err != nil { + return nil, err + } + if len(kandangIDsGrowing) == 0 { + return nil, nil + } + + totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) + if err != nil { + return nil, err + } + if totalPopulationFlockGrowing <= 0 { + return nil, nil + } + + ratio := transferTotalQty / totalPopulationFlockGrowing + if ratio <= 0 { + return nil, nil + } + + var rows []commonRepo.HppV2ExpenseCostRow + if farmLevel { + rows, err = s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), sourceProjectFlockID, endDate, config.Ekspedisi) + } else { + rows, err = s.hppRepo.ListExpenseRealizationRowsByProjectFlockKandangIDs(context.Background(), kandangIDsGrowing, endDate, config.Ekspedisi) + } + if err != nil { + return nil, err + } + + return buildExpensePartFromRows( + rows, + map[bool]string{false: hppV2PartGrowingDirect, true: hppV2PartGrowingFarm}[farmLevel], + map[bool]string{false: "Growing Direct", true: "Growing Farm"}[farmLevel], + &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: transferTotalQty, + Denominator: totalPopulationFlockGrowing, + Ratio: ratio, + }, + ratio, + ), nil +} + +func (s *hppV2Service) buildLayingExpenseDirectPart( + projectFlockKandangId uint, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + rows, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockKandangIDs(context.Background(), []uint{projectFlockKandangId}, endDate, config.Ekspedisi) + if err != nil { + return nil, err + } + + return buildExpensePartFromRows(rows, hppV2PartLayingDirect, "Laying Direct", nil, 1), nil +} + +func (s *hppV2Service) buildLayingExpenseFarmPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + rows, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, endDate, config.Ekspedisi) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + + farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) + if err != nil { + return nil, err + } + farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate) + if err != nil { + return nil, err + } + + basis := hppV2ProrationEggWeight + numerator := targetWeight + denominator := farmWeight + if denominator <= 0 { + basis = hppV2ProrationEggPiece + numerator = targetPieces + denominator = farmPieces + } + if denominator <= 0 { + return nil, nil + } + + ratio := numerator / denominator + if ratio <= 0 { + return nil, nil + } + + return buildExpensePartFromRows( + rows, + hppV2PartLayingFarm, + "Laying Farm", + &HppV2Proration{ + Basis: basis, + Numerator: numerator, + Denominator: denominator, + Ratio: ratio, + }, + ratio, + ), nil } func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { @@ -155,9 +757,71 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) } - result := &HppCostResponse{ + return &HppCostResponse{ Estimation: estimation, Real: real, - } - return result, nil + }, nil +} + +func hppV2DayWindow(date *time.Time) (time.Time, time.Time, error) { + if date == nil { + now := time.Now() + date = &now + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return time.Time{}, time.Time{}, err + } + + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) + endOfDay := startOfDay.Add(24 * time.Hour) + return startOfDay, endOfDay, nil +} + +func adjustmentRowTotalCost(row commonRepo.HppV2AdjustmentCostRow) float64 { + if row.GrandTotal > 0 { + return row.GrandTotal + } + return row.Qty * row.Price +} + +func buildExpensePartFromRows( + rows []commonRepo.HppV2ExpenseCostRow, + code string, + title string, + proration *HppV2Proration, + ratio float64, +) *HppV2ComponentPart { + if len(rows) == 0 { + return nil + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + total += row.TotalCost * ratio + references = append(references, HppV2Reference{ + Type: "expense_realization", + ID: row.ExpenseRealizationID, + ProductID: row.NonstockID, + ProductName: row.NonstockName, + Date: row.RealizationDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.Price, + Total: row.TotalCost, + AppliedTotal: row.TotalCost * ratio, + }) + } + if total == 0 { + return nil + } + + return &HppV2ComponentPart{ + Code: code, + Title: title, + Total: total, + Proration: proration, + References: references, + } } diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go new file mode 100644 index 00000000..1a7aa2a1 --- /dev/null +++ b/internal/common/service/common.hppv2.service_test.go @@ -0,0 +1,473 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" + "testing" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type hppV2RepoStub struct { + contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext + pfkIDsByProject map[uint][]uint + usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow + adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow + expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow + expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow + totalPopulationByKey map[string]float64 + transferSummaryByPFK map[uint]struct { + projectFlockID uint + totalQty float64 + } + eggProductionByPFK map[uint]struct { + pieces float64 + kg float64 + } + eggSalesByPFK map[uint]struct { + pieces float64 + kg float64 + } +} + +func (s *hppV2RepoStub) GetProjectFlockKandangContext(_ context.Context, projectFlockKandangId uint) (*commonRepo.HppV2ProjectFlockKandangContext, error) { + row := s.contextByPFK[projectFlockKandangId] + if row == nil { + return nil, fmt.Errorf("pfk %d not found", projectFlockKandangId) + } + return row, nil +} + +func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFlockId uint) ([]uint, error) { + return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil +} + +func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) { + return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil +} + +func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) { + return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil +} + +func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) { + return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil +} + +func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) { + return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil +} + +func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) { + return 0, nil +} + +func (s *hppV2RepoStub) GetTotalPopulation(_ context.Context, projectFlockKandangIDs []uint) (float64, error) { + return s.totalPopulationByKey[stubKey(projectFlockKandangIDs, nil)], nil +} + +func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, error) { + totalPieces := 0.0 + totalKg := 0.0 + for _, projectFlockKandangID := range projectFlockKandangIDs { + row := s.eggProductionByPFK[projectFlockKandangID] + totalPieces += row.pieces + totalKg += row.kg + } + return totalPieces, totalKg, nil +} + +func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) { + if len(projectFlockKandangIDs) != 1 { + return 0, 0, nil + } + row := s.eggSalesByPFK[projectFlockKandangIDs[0]] + return row.pieces, row.kg, nil +} + +func (s *hppV2RepoStub) GetTransferSourceSummary(_ context.Context, projectFlockKandangId uint) (uint, float64, error) { + row := s.transferSummaryByPFK[projectFlockKandangId] + return row.projectFlockID, row.totalQty, nil +} + +func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 10: { + ProjectFlockKandangID: 10, + ProjectFlockID: 2, + ProjectFlockCategory: "LAYING", + KandangID: 100, + KandangName: "Kandang A", + LocationID: 16, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 1: {101, 102}, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{101, 102}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9001, SourceProductID: 8, SourceProductName: "Pakan Growing", Qty: 100, UnitPrice: 40, TotalCost: 4000}, + }, + stubKey([]uint{10}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9002, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 50, UnitPrice: 30, TotalCost: 1500}, + }, + }, + adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ + stubKey([]uint{101, 102}, []string{"PAKAN-CUTOVER"}): { + {AdjustmentID: 8001, ProductID: 11, ProductName: "Pakan Growing Cut-over", Qty: 20, Price: 30, GrandTotal: 600}, + }, + stubKey([]uint{10}, []string{"PAKAN-CUTOVER"}): { + {AdjustmentID: 8002, ProductID: 12, ProductName: "Pakan Laying Cut-over", Qty: 10, Price: 30, GrandTotal: 300}, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{101, 102}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 10: {projectFlockID: 1, totalQty: 250}, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 10: {pieces: 100, kg: 10}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 10: {pieces: 40, kg: 4}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(10, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected breakdown result") + } + if got := result.TotalProductionCost; got != 2950 { + t.Fatalf("expected total production cost 2950, got %v", got) + } + if len(result.Components) != 1 { + t.Fatalf("expected 1 component, got %d", len(result.Components)) + } + component := result.Components[0] + if component.Code != "PAKAN" { + t.Fatalf("expected PAKAN component, got %s", component.Code) + } + partTotals := map[string]float64{} + for _, part := range component.Parts { + partTotals[part.Code] = part.Total + } + if partTotals[hppV2PartGrowingNormal] != 1000 { + t.Fatalf("expected growing normal 1000, got %v", partTotals[hppV2PartGrowingNormal]) + } + if partTotals[hppV2PartGrowingCutover] != 150 { + t.Fatalf("expected growing cutover 150, got %v", partTotals[hppV2PartGrowingCutover]) + } + if partTotals[hppV2PartLayingNormal] != 1500 { + t.Fatalf("expected laying normal 1500, got %v", partTotals[hppV2PartLayingNormal]) + } + if partTotals[hppV2PartLayingCutover] != 300 { + t.Fatalf("expected laying cutover 300, got %v", partTotals[hppV2PartLayingCutover]) + } + if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 { + t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration) + } + if result.Hpp.Estimation.HargaKg != 295 { + t.Fatalf("expected estimation harga/kg 295, got %v", result.Hpp.Estimation.HargaKg) + } + if result.Hpp.Real.HargaKg != 737.5 { + t.Fatalf("expected real harga/kg 737.5, got %v", result.Hpp.Real.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_ManualCutoverUsesLayingSlicesOnly(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 20: { + ProjectFlockKandangID: 20, + ProjectFlockID: 3, + ProjectFlockCategory: "LAYING", + KandangID: 200, + KandangName: "Kandang B", + LocationID: 17, + }, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{20}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9100, SourceProductID: 21, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 10, TotalCost: 200}, + }, + }, + adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ + stubKey([]uint{20}, []string{"PAKAN-CUTOVER"}): { + {AdjustmentID: 8100, ProductID: 22, ProductName: "Pakan Laying Cut-over", Qty: 30, Price: 10, GrandTotal: 300}, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 20: {pieces: 50, kg: 5}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 20: {pieces: 25, kg: 2.5}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(20, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result.TotalProductionCost != 500 { + t.Fatalf("expected total production cost 500, got %v", result.TotalProductionCost) + } + component := result.Components[0] + if len(component.Parts) != 2 { + t.Fatalf("expected 2 laying parts, got %d", len(component.Parts)) + } + for _, part := range component.Parts { + if strings.HasPrefix(part.Code, "growing_") { + t.Fatalf("expected no growing parts, got %s", part.Code) + } + } + if result.Hpp.Estimation.HargaKg != 100 { + t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 30: { + ProjectFlockKandangID: 30, + ProjectFlockID: 4, + ProjectFlockCategory: "LAYING", + KandangID: 300, + KandangName: "Kandang C", + LocationID: 18, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 5: {301, 302}, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{30}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9200, SourceProductID: 31, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 25, TotalCost: 500}, + }, + stubKey([]uint{301, 302}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): { + {StockableType: "purchase_items", StockableID: 9201, SourceProductID: 32, SourceProductName: "OVK Growing", Qty: 40, UnitPrice: 10, TotalCost: 400}, + }, + stubKey([]uint{30}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): { + {StockableType: "purchase_items", StockableID: 9202, SourceProductID: 33, SourceProductName: "OVK Laying", Qty: 15, UnitPrice: 10, TotalCost: 150}, + }, + }, + adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ + stubKey([]uint{301, 302}, []string{"OVK-CUTOVER"}): { + {AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100}, + }, + stubKey([]uint{30}, []string{"OVK-CUTOVER"}): { + {AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50}, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{301, 302}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 30: {projectFlockID: 5, totalQty: 500}, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 30: {pieces: 120, kg: 12}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 30: {pieces: 60, kg: 6}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(30, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected breakdown result") + } + if len(result.Components) != 2 { + t.Fatalf("expected 2 components, got %d", len(result.Components)) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + + if componentTotals[hppV2ComponentPakan] != 500 { + t.Fatalf("expected pakan total 500, got %v", componentTotals[hppV2ComponentPakan]) + } + if componentTotals[hppV2ComponentOvk] != 450 { + t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk]) + } + if result.TotalProductionCost != 950 { + t.Fatalf("expected total production cost 950, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 79.17 { + t.Fatalf("expected estimation harga/kg 79.17, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 40: { + ProjectFlockKandangID: 40, + ProjectFlockID: 6, + ProjectFlockCategory: "LAYING", + KandangID: 400, + KandangName: "Kandang D", + LocationID: 19, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 6: {40, 41}, + 7: {701, 702}, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{701, 702}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 40: {projectFlockID: 7, totalQty: 200}, + }, + expenseRowsByPFKKey: map[string][]commonRepo.HppV2ExpenseCostRow{ + expenseStubKey([]uint{701, 702}, false): { + {ExpenseRealizationID: 1, NonstockID: 11, NonstockName: "Growing BOP", Qty: 1, Price: 500, TotalCost: 500, RealizationDate: mustTime(t, "2026-04-10")}, + }, + expenseStubKey([]uint{40}, false): { + {ExpenseRealizationID: 2, NonstockID: 12, NonstockName: "Laying BOP", Qty: 1, Price: 80, TotalCost: 80, RealizationDate: mustTime(t, "2026-04-19")}, + }, + expenseStubKey([]uint{701, 702}, true): { + {ExpenseRealizationID: 3, NonstockID: 13, NonstockName: "Growing Expedition", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-11")}, + }, + expenseStubKey([]uint{40}, true): { + {ExpenseRealizationID: 4, NonstockID: 14, NonstockName: "Laying Expedition", Qty: 1, Price: 40, TotalCost: 40, RealizationDate: mustTime(t, "2026-04-19")}, + }, + }, + expenseRowsByFarmKey: map[string][]commonRepo.HppV2ExpenseCostRow{ + expenseFarmKey(7, false): { + {ExpenseRealizationID: 5, NonstockID: 15, NonstockName: "Growing Farm BOP", Qty: 1, Price: 300, TotalCost: 300, RealizationDate: mustTime(t, "2026-04-12")}, + }, + expenseFarmKey(6, false): { + {ExpenseRealizationID: 6, NonstockID: 16, NonstockName: "Laying Farm BOP", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-19")}, + }, + expenseFarmKey(7, true): { + {ExpenseRealizationID: 7, NonstockID: 17, NonstockName: "Growing Farm Expedition", Qty: 1, Price: 50, TotalCost: 50, RealizationDate: mustTime(t, "2026-04-12")}, + }, + expenseFarmKey(6, true): { + {ExpenseRealizationID: 8, NonstockID: 18, NonstockName: "Laying Farm Expedition", Qty: 1, Price: 60, TotalCost: 60, RealizationDate: mustTime(t, "2026-04-19")}, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 40: {pieces: 30, kg: 3}, + 41: {pieces: 70, kg: 7}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 40: {pieces: 50, kg: 5}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(40, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + + if componentTotals[hppV2ComponentBopRegular] != 270 { + t.Fatalf("expected regular BOP total 270, got %v", componentTotals[hppV2ComponentBopRegular]) + } + if componentTotals[hppV2ComponentBopEksp] != 88 { + t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp]) + } + if result.TotalProductionCost != 358 { + t.Fatalf("expected total production cost 358, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 119.33 { + t.Fatalf("expected estimation harga/kg 119.33, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func stubKey(ids []uint, flags []string) string { + idParts := make([]string, 0, len(ids)) + for _, id := range ids { + idParts = append(idParts, fmt.Sprintf("%d", id)) + } + sort.Strings(idParts) + + flagParts := append([]string{}, flags...) + sort.Strings(flagParts) + + return strings.Join(idParts, ",") + "|" + strings.Join(flagParts, ",") +} + +func mustDate(t *testing.T, raw string) *time.Time { + t.Helper() + loc, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed to load timezone: %v", err) + } + value, err := time.ParseInLocation("2006-01-02", raw, loc) + if err != nil { + t.Fatalf("failed to parse date %s: %v", raw, err) + } + return &value +} + +func mustTime(t *testing.T, raw string) time.Time { + t.Helper() + value := mustDate(t, raw) + return *value +} + +func expenseStubKey(ids []uint, ekspedisi bool) string { + return stubKey(ids, []string{fmt.Sprintf("ekspedisi=%t", ekspedisi)}) +} + +func expenseFarmKey(projectFlockID uint, ekspedisi bool) string { + return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi) +} diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 181990a1..691cafc0 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -498,6 +498,29 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { }) } +func (c *RepportController) GetHppV2Breakdown(ctx *fiber.Ctx) error { + query := &validation.HppV2BreakdownQuery{ + ProjectFlockKandangID: uint(ctx.QueryInt("project_flock_kandang_id", 0)), + Period: ctx.Query("period", ""), + } + + if err := m.EnsureProjectFlockKandangAccess(ctx, c.RepportService.DB(), 0, query.ProjectFlockKandangID); err != nil { + return err + } + + data, err := c.RepportService.GetHppV2Breakdown(ctx, query) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get HPP v2 breakdown successfully", + Data: data, + }) +} + func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return parseCommaSeparatedInt64sWithField(raw, "supplier_ids") } diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index be6da322..16c14de5 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -23,6 +23,7 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) + route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index f2473721..0a49ed9f 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -49,6 +49,7 @@ type RepportService interface { GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) + GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) DB() *gorm.DB @@ -2146,6 +2147,27 @@ func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Locatio return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc) } +func (s *repportService) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if s.HppV2Svc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "hpp v2 service is not configured") + } + + periodDate, err := time.ParseInLocation("2006-01-02", params.Period, time.FixedZone("Asia/Jakarta", 7*60*60)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD") + } + + result, err := s.HppV2Svc.CalculateHppBreakdown(params.ProjectFlockKandangID, &periodDate) + if err != nil { + return nil, err + } + + return result, nil +} + func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 7130346c..27f1d741 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -75,6 +75,11 @@ type HppPerKandangQuery struct { WeightMax *float64 `query:"-"` } +type HppV2BreakdownQuery struct { + ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"required,gt=0"` + Period string `query:"period" validate:"required,datetime=2006-01-02"` +} + type ExpenseDepreciationQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"`