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" 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 upsertedRow *entity.FarmDepreciationManualInput deleteCalled bool deleteDate time.Time deleteFarmIDs []uint } 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) 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 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 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 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) } }