package service import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" dto "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" "gorm.io/gorm" ) type expenseDepreciationRepoMock struct { repportRepo.ExpenseDepreciationRepository manualInputs []repportRepo.FarmDepreciationManualInputRow candidateRows []repportRepo.FarmDepreciationCandidateRow snapshots []entity.FarmDepreciationSnapshot upsertedRow *entity.FarmDepreciationManualInput deleteCalled bool deleteDate time.Time deleteFarmIDs []uint upsertSnapshotCalls int upsertedSnapshots []entity.FarmDepreciationSnapshot getSnapshotsCalls int } func (m *expenseDepreciationRepoMock) DB() *gorm.DB { return nil } func (m *expenseDepreciationRepoMock) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error { if row == nil { return nil } cloned := *row if cloned.Id == 0 { cloned.Id = 123 } m.upsertedRow = &cloned row.Id = cloned.Id return nil } func (m *expenseDepreciationRepoMock) GetCandidateFarms(_ context.Context, _ time.Time, _ []int64, _ []int64, _ []int64) ([]repportRepo.FarmDepreciationCandidateRow, error) { return append([]repportRepo.FarmDepreciationCandidateRow{}, m.candidateRows...), nil } func (m *expenseDepreciationRepoMock) GetSnapshotsByPeriodAndFarmIDs(_ context.Context, period time.Time, farmIDs []uint) ([]entity.FarmDepreciationSnapshot, error) { m.getSnapshotsCalls++ if len(farmIDs) == 0 { return []entity.FarmDepreciationSnapshot{}, nil } allowed := make(map[uint]struct{}, len(farmIDs)) for _, farmID := range farmIDs { allowed[farmID] = struct{}{} } result := make([]entity.FarmDepreciationSnapshot, 0, len(m.snapshots)) for _, row := range m.snapshots { if _, ok := allowed[row.ProjectFlockId]; !ok { continue } if row.PeriodDate.IsZero() || row.PeriodDate.Format("2006-01-02") == period.Format("2006-01-02") { result = append(result, row) } } return result, nil } func (m *expenseDepreciationRepoMock) UpsertSnapshots(_ context.Context, rows []entity.FarmDepreciationSnapshot) error { m.upsertSnapshotCalls++ m.upsertedSnapshots = append([]entity.FarmDepreciationSnapshot{}, rows...) return nil } func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error { m.deleteCalled = true m.deleteDate = fromDate m.deleteFarmIDs = append([]uint{}, farmIDs...) return nil } func (m *expenseDepreciationRepoMock) GetLatestManualInputsByFarms(_ context.Context, _ []int64, _ []int64, _ []int64) ([]repportRepo.FarmDepreciationManualInputRow, error) { return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil } type expenseRealizationRepoMock struct { expenseRepo.ExpenseRealizationRepository db *gorm.DB } func (m *expenseRealizationRepoMock) DB() *gorm.DB { return m.db } type hppCostRepoMock struct { commonRepo.HppCostRepository kandangIDsByFarm map[uint][]uint } func (m *hppCostRepoMock) GetProjectFlockKandangIDs(_ context.Context, projectFlockID uint) ([]uint, error) { return append([]uint{}, m.kandangIDsByFarm[projectFlockID]...), nil } type hppV2ServiceMock struct { approvalService.HppV2Service breakdownByPFK map[uint]*approvalService.HppV2Breakdown } func (m *hppV2ServiceMock) CalculateHppBreakdown(projectFlockKandangId uint, _ *time.Time) (*approvalService.HppV2Breakdown, error) { return m.breakdownByPFK[projectFlockKandangId], nil } func TestComputeExpenseDepreciationSnapshots_FromHppV2NormalTransfer(t *testing.T) { periodDate := mustJakartaDate(t, "2026-06-05") svc := &repportService{ HppCostRepo: &hppCostRepoMock{ kandangIDsByFarm: map[uint][]uint{ 1: {10}, }, }, HppV2Svc: &hppV2ServiceMock{ breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ 10: { ProjectFlockKandangID: 10, KandangID: 100, KandangName: "Kandang A", HouseType: "close_house", Components: []approvalService.HppV2Component{ { Code: "DEPRECIATION", Title: "Depreciation", Total: 100, Parts: []approvalService.HppV2ComponentPart{ { Code: "normal_transfer", Total: 100, Details: map[string]any{ "schedule_day": 2, "depreciation_percent": 10.0, "pullet_cost_day_n": 1000.0, "source_project_flock_id": 77, "origin_date": "2026-01-01", }, References: []approvalService.HppV2Reference{ { Type: "laying_transfer", ID: 701, Date: "2026-05-20", Qty: 150, }, }, }, }, }, }, }, }, }, } rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{1}, map[uint]string{1: "Farm A"}) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } if rows[0].DepreciationValue != 100 { t.Fatalf("expected depreciation value 100, got %v", rows[0].DepreciationValue) } if rows[0].PulletCostDayNTotal != 1000 { t.Fatalf("expected pullet cost day n 1000, got %v", rows[0].PulletCostDayNTotal) } assertFloatEqual(t, rows[0].DepreciationPercentEffective, 10) components := decodeDepreciationComponents(t, rows[0].Components) if components.KandangCount != 1 { t.Fatalf("expected kandang_count 1, got %d", components.KandangCount) } entry := components.Kandang[0] if entry.ProjectFlockKandangID != 10 || entry.KandangID != 100 || entry.KandangName != "Kandang A" { t.Fatalf("unexpected kandang identity: %+v", entry) } if entry.TransferID != 701 || entry.TransferDate != "2026-05-20" || entry.TransferQty != 150 { t.Fatalf("unexpected transfer metadata: %+v", entry) } if entry.DepreciationSource != "normal_transfer" { t.Fatalf("expected depreciation_source normal_transfer, got %q", entry.DepreciationSource) } if entry.ManualInputID != nil || entry.CutoverDate != "" || entry.StartScheduleDay != nil { t.Fatalf("expected manual fields empty for normal transfer, got %+v", entry) } } func TestComputeExpenseDepreciationSnapshots_FromHppV2ManualCutover(t *testing.T) { periodDate := mustJakartaDate(t, "2026-06-05") svc := &repportService{ HppCostRepo: &hppCostRepoMock{ kandangIDsByFarm: map[uint][]uint{ 2: {20}, }, }, HppV2Svc: &hppV2ServiceMock{ breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ 20: { ProjectFlockKandangID: 20, KandangID: 200, KandangName: "Kandang B", HouseType: "open_house", Components: []approvalService.HppV2Component{ { Code: "DEPRECIATION", Title: "Depreciation", Total: 200, Parts: []approvalService.HppV2ComponentPart{ { Code: "manual_cutover", Total: 200, Details: map[string]any{ "schedule_day": 2, "start_schedule_day": 2, "depreciation_percent": 25.0, "pullet_cost_day_n": 800.0, "manual_input_id": 901, "cutover_date": "2026-06-01", "origin_date": "2026-01-01", }, References: []approvalService.HppV2Reference{ { Type: "farm_depreciation_manual_input", ID: 901, Date: "2026-06-01", }, }, }, }, }, }, }, }, }, } rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{2}, map[uint]string{2: "Farm B"}) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } assertFloatEqual(t, rows[0].DepreciationPercentEffective, 25) components := decodeDepreciationComponents(t, rows[0].Components) if components.KandangCount != 1 { t.Fatalf("expected kandang_count 1, got %d", components.KandangCount) } entry := components.Kandang[0] if entry.DepreciationSource != "manual_cutover" { t.Fatalf("expected depreciation_source manual_cutover, got %q", entry.DepreciationSource) } if entry.TransferID != 0 || entry.TransferDate != "" || entry.TransferQty != 0 { t.Fatalf("expected transfer fields empty for manual path, got %+v", entry) } if entry.ManualInputID == nil || *entry.ManualInputID != 901 { t.Fatalf("expected manual_input_id 901, got %+v", entry.ManualInputID) } if entry.CutoverDate != "2026-06-01" || entry.OriginDate != "2026-01-01" { t.Fatalf("unexpected manual date fields: %+v", entry) } if entry.StartScheduleDay == nil || *entry.StartScheduleDay != 2 { t.Fatalf("expected start_schedule_day 2, got %+v", entry.StartScheduleDay) } } func TestComputeExpenseDepreciationSnapshots_AggregatesMultipleKandang(t *testing.T) { periodDate := mustJakartaDate(t, "2026-06-05") svc := &repportService{ HppCostRepo: &hppCostRepoMock{ kandangIDsByFarm: map[uint][]uint{ 3: {30, 31}, }, }, HppV2Svc: &hppV2ServiceMock{ breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ 30: { ProjectFlockKandangID: 30, KandangID: 300, KandangName: "Kandang C1", Components: []approvalService.HppV2Component{ { Code: "DEPRECIATION", Parts: []approvalService.HppV2ComponentPart{ { Code: "normal_transfer", Total: 50, Details: map[string]any{ "schedule_day": 1, "depreciation_percent": 10.0, "pullet_cost_day_n": 500.0, }, }, }, }, }, }, 31: { ProjectFlockKandangID: 31, KandangID: 301, KandangName: "Kandang C2", Components: []approvalService.HppV2Component{ { Code: "DEPRECIATION", Parts: []approvalService.HppV2ComponentPart{ { Code: "normal_transfer", Total: 100, Details: map[string]any{ "schedule_day": 2, "depreciation_percent": 10.0, "pullet_cost_day_n": 1000.0, }, }, }, }, }, }, }, }, } rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{3}, map[uint]string{3: "Farm C"}) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } if rows[0].DepreciationValue != 150 { t.Fatalf("expected depreciation value 150, got %v", rows[0].DepreciationValue) } if rows[0].PulletCostDayNTotal != 1500 { t.Fatalf("expected pullet cost day n 1500, got %v", rows[0].PulletCostDayNTotal) } assertFloatEqual(t, rows[0].DepreciationPercentEffective, 10) components := decodeDepreciationComponents(t, rows[0].Components) if components.KandangCount != 2 { t.Fatalf("expected kandang_count 2, got %d", components.KandangCount) } } func TestComputeExpenseDepreciationSnapshots_ZeroWhenDepreciationMissing(t *testing.T) { periodDate := mustJakartaDate(t, "2026-06-05") svc := &repportService{ HppCostRepo: &hppCostRepoMock{ kandangIDsByFarm: map[uint][]uint{ 4: {40}, }, }, HppV2Svc: &hppV2ServiceMock{ breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ 40: { ProjectFlockKandangID: 40, KandangID: 400, KandangName: "Kandang D", Components: []approvalService.HppV2Component{ {Code: "PAKAN", Total: 123}, }, }, }, }, } rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{4}, map[uint]string{4: "Farm D"}) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } if rows[0].DepreciationValue != 0 || rows[0].PulletCostDayNTotal != 0 || rows[0].DepreciationPercentEffective != 0 { t.Fatalf("expected zero snapshot values, got %+v", rows[0]) } components := decodeDepreciationComponents(t, rows[0].Components) if components.KandangCount != 0 || len(components.Kandang) != 0 { t.Fatalf("expected empty components, got %+v", components) } } func TestGetExpenseDepreciation_UsesExistingSnapshotWhenForceRecomputeFalse(t *testing.T) { periodDate := mustJakartaDate(t, "2026-06-05") repo := &expenseDepreciationRepoMock{ candidateRows: []repportRepo.FarmDepreciationCandidateRow{ {ProjectFlockID: 1, FarmName: "Farm A"}, }, snapshots: []entity.FarmDepreciationSnapshot{ { ProjectFlockId: 1, PeriodDate: periodDate, DepreciationPercentEffective: 11.1, DepreciationValue: 111, PulletCostDayNTotal: 1001, Components: json.RawMessage(`{"kandang_count":0,"kandang":[]}`), }, }, } svc := &repportService{ Validate: validator.New(), ExpenseDepreciationRepo: repo, ExpenseRealizationRepo: &expenseRealizationRepoMock{}, HppCostRepo: &hppCostRepoMock{}, HppV2Svc: &hppV2ServiceMock{}, } rows, meta, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05") if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } if rows[0].DepreciationValue != 111 { t.Fatalf("expected depreciation value 111, got %v", rows[0].DepreciationValue) } if meta == nil || meta.TotalResults != 1 { t.Fatalf("expected meta total_results 1, got %+v", meta) } if repo.upsertSnapshotCalls != 0 { t.Fatalf("expected no snapshot upsert, got %d", repo.upsertSnapshotCalls) } if repo.getSnapshotsCalls != 1 { t.Fatalf("expected snapshot fetch called once, got %d", repo.getSnapshotsCalls) } } func TestGetExpenseDepreciation_ForceRecomputeRebuildsAllSnapshots(t *testing.T) { periodDate := mustJakartaDate(t, "2026-06-05") repo := &expenseDepreciationRepoMock{ candidateRows: []repportRepo.FarmDepreciationCandidateRow{ {ProjectFlockID: 1, FarmName: "Farm A"}, }, snapshots: []entity.FarmDepreciationSnapshot{ { ProjectFlockId: 1, PeriodDate: periodDate, DepreciationValue: 999, PulletCostDayNTotal: 999, }, }, } svc := &repportService{ Validate: validator.New(), ExpenseDepreciationRepo: repo, ExpenseRealizationRepo: &expenseRealizationRepoMock{}, HppCostRepo: &hppCostRepoMock{ kandangIDsByFarm: map[uint][]uint{ 1: {10}, }, }, HppV2Svc: &hppV2ServiceMock{ breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ 10: depreciationBreakdown(10, 100, "Kandang A", 100, 1000, 10), }, }, } rows, _, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05&force_recompute=true") if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } if rows[0].DepreciationValue != 100 { t.Fatalf("expected recomputed depreciation value 100, got %v", rows[0].DepreciationValue) } if repo.upsertSnapshotCalls != 1 { t.Fatalf("expected snapshot upsert called once, got %d", repo.upsertSnapshotCalls) } if len(repo.upsertedSnapshots) != 1 || repo.upsertedSnapshots[0].ProjectFlockId != 1 { t.Fatalf("expected upserted snapshot for farm 1, got %+v", repo.upsertedSnapshots) } if repo.getSnapshotsCalls != 0 { t.Fatalf("expected no snapshot fetch in force recompute mode, got %d", repo.getSnapshotsCalls) } } func TestGetExpenseDepreciation_ForceRecomputeFalseComputesOnlyMissingFarms(t *testing.T) { periodDate := mustJakartaDate(t, "2026-06-05") repo := &expenseDepreciationRepoMock{ candidateRows: []repportRepo.FarmDepreciationCandidateRow{ {ProjectFlockID: 1, FarmName: "Farm A"}, {ProjectFlockID: 2, FarmName: "Farm B"}, }, snapshots: []entity.FarmDepreciationSnapshot{ { ProjectFlockId: 1, PeriodDate: periodDate, DepreciationPercentEffective: 11.1, DepreciationValue: 111, PulletCostDayNTotal: 1001, Components: json.RawMessage(`{"kandang_count":0,"kandang":[]}`), }, }, } svc := &repportService{ Validate: validator.New(), ExpenseDepreciationRepo: repo, ExpenseRealizationRepo: &expenseRealizationRepoMock{}, HppCostRepo: &hppCostRepoMock{ kandangIDsByFarm: map[uint][]uint{ 1: {10}, 2: {20}, }, }, HppV2Svc: &hppV2ServiceMock{ breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ 10: depreciationBreakdown(10, 100, "Kandang A", 999, 9999, 10), 20: depreciationBreakdown(20, 200, "Kandang B", 200, 2000, 10), }, }, } rows, _, err := getExpenseDepreciationByQuery(t, svc, "page=1&limit=10&period=2026-06-05") if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 2 { t.Fatalf("expected 2 rows, got %d", len(rows)) } if rows[0].ProjectFlockID != 1 || rows[0].DepreciationValue != 111 { t.Fatalf("expected farm 1 use existing snapshot value 111, got %+v", rows[0]) } if rows[1].ProjectFlockID != 2 || rows[1].DepreciationValue != 200 { t.Fatalf("expected farm 2 recomputed value 200, got %+v", rows[1]) } if repo.upsertSnapshotCalls != 1 { t.Fatalf("expected one upsert call for missing farms, got %d", repo.upsertSnapshotCalls) } if len(repo.upsertedSnapshots) != 1 || repo.upsertedSnapshots[0].ProjectFlockId != 2 { t.Fatalf("expected upsert only farm 2 snapshot, got %+v", repo.upsertedSnapshots) } if repo.getSnapshotsCalls != 1 { t.Fatalf("expected snapshot fetch called once, got %d", repo.getSnapshotsCalls) } } func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDate(t *testing.T) { repo := &expenseDepreciationRepoMock{ manualInputs: []repportRepo.FarmDepreciationManualInputRow{ { Id: 123, ProjectFlockID: 99, FarmName: "Farm Z", TotalCost: 1000, CutoverDate: mustJakartaDate(t, "2026-06-01"), }, }, } svc := &repportService{ Validate: validator.New(), ExpenseDepreciationRepo: repo, } reqPayload := &validation.ExpenseDepreciationManualInputUpsert{ ProjectFlockID: 99, TotalCost: 1000, CutoverDate: "2026-06-01", } app := fiber.New() var response *dto.ExpenseDepreciationManualInputRowDTO app.Put("/", func(c *fiber.Ctx) error { result, err := svc.UpsertExpenseDepreciationManualInput(c, reqPayload) if err != nil { return err } response = result return c.SendStatus(fiber.StatusOK) }) httpResp, err := app.Test(httptest.NewRequest(http.MethodPut, "/", nil)) if err != nil { t.Fatalf("expected no app error, got %v", err) } if httpResp.StatusCode != fiber.StatusOK { t.Fatalf("expected status %d, got %d", fiber.StatusOK, httpResp.StatusCode) } if !repo.deleteCalled { t.Fatal("expected DeleteSnapshotsFromDate to be called") } if len(repo.deleteFarmIDs) != 1 || repo.deleteFarmIDs[0] != 99 { t.Fatalf("expected delete farm ids [99], got %v", repo.deleteFarmIDs) } if repo.deleteDate.Format("2006-01-02") != "2026-06-01" { t.Fatalf("expected delete date 2026-06-01, got %s", repo.deleteDate.Format("2006-01-02")) } if response == nil { t.Fatal("expected response") } if response.FarmName != "Farm Z" { t.Fatalf("expected farm name Farm Z, got %s", response.FarmName) } } func getExpenseDepreciationByQuery(t *testing.T, svc *repportService, query string) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) { t.Helper() app := fiber.New() var ( rows []dto.ExpenseDepreciationRowDTO meta *dto.ExpenseDepreciationMetaDTO ) app.Get("/", func(c *fiber.Ctx) error { resultRows, resultMeta, err := svc.GetExpenseDepreciation(c) if err != nil { return err } rows = resultRows meta = resultMeta return c.SendStatus(fiber.StatusOK) }) target := "/" if query != "" { target += "?" + query } resp, err := app.Test(httptest.NewRequest(http.MethodGet, target, nil)) if err != nil { return nil, nil, err } if resp.StatusCode != fiber.StatusOK { return nil, nil, fiber.NewError(resp.StatusCode, "request failed") } return rows, meta, nil } func depreciationBreakdown( projectFlockKandangID uint, kandangID uint, kandangName string, depreciationValue float64, pulletCostDayN float64, depreciationPercent float64, ) *approvalService.HppV2Breakdown { return &approvalService.HppV2Breakdown{ ProjectFlockKandangID: projectFlockKandangID, KandangID: kandangID, KandangName: kandangName, HouseType: "close_house", Components: []approvalService.HppV2Component{ { Code: "DEPRECIATION", Title: "Depreciation", Total: depreciationValue, Parts: []approvalService.HppV2ComponentPart{ { Code: "normal_transfer", Total: depreciationValue, Details: map[string]any{ "schedule_day": 1, "depreciation_percent": depreciationPercent, "pullet_cost_day_n": pulletCostDayN, "source_project_flock_id": 77, "origin_date": "2026-01-01", }, References: []approvalService.HppV2Reference{ { Type: "laying_transfer", ID: 701, Date: "2026-05-20", Qty: 100, }, }, }, }, }, }, } } func decodeDepreciationComponents(t *testing.T, raw []byte) depreciationFarmComponents { t.Helper() var out depreciationFarmComponents if len(raw) == 0 { return out } if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("failed to decode components: %v", err) } return out } func mustJakartaDate(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", raw, location) if err != nil { t.Fatalf("failed parsing date %q: %v", raw, err) } return value } func assertFloatEqual(t *testing.T, got float64, want float64) { t.Helper() const epsilon = 0.000001 if got > want+epsilon || got < want-epsilon { t.Fatalf("expected %.6f, got %.6f", want, got) } }