diff --git a/cmd/import-farm-depreciation-manual-inputs/main.go b/cmd/import-farm-depreciation-manual-inputs/main.go new file mode 100644 index 00000000..51c0fe66 --- /dev/null +++ b/cmd/import-farm-depreciation-manual-inputs/main.go @@ -0,0 +1,632 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +const dateLayout = "2006-01-02" + +type importOptions struct { + FilePath string + Sheet string + Apply bool +} + +type headerIndexes struct { + ProjectFlockID int + TotalCost int + CutoverDate int + Note int +} + +type manualInputImportRow struct { + RowNumber int + ProjectFlockID uint + TotalCost float64 + CutoverDate time.Time + Note *string +} + +type validationIssue struct { + Row int + Field string + Message string +} + +func (i validationIssue) Error() string { + if i.Row > 0 { + return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message) + } + return fmt.Sprintf("field=%s message=%s", i.Field, i.Message) +} + +type farmResolver interface { + ResolveActiveLayingFarms(ctx context.Context, projectFlockIDs []uint) (map[uint]string, error) +} + +type dbFarmResolver struct { + db *gorm.DB +} + +type manualInputStore interface { + UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error + DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error +} + +type txRunner interface { + InTx(ctx context.Context, fn func(store manualInputStore) error) error +} + +type dbTxRunner struct { + db *gorm.DB +} + +type expenseDepreciationStore struct { + repo repportRepo.ExpenseDepreciationRepository +} + +type farmIdentityRow struct { + ID uint `gorm:"column:id"` + FarmName string `gorm:"column:farm_name"` +} + +func main() { + var opts importOptions + flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)") + flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)") + flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run") + flag.Parse() + + opts.FilePath = strings.TrimSpace(opts.FilePath) + opts.Sheet = strings.TrimSpace(opts.Sheet) + + if opts.FilePath == "" { + log.Fatal("--file is required") + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + log.Fatalf("failed to load timezone Asia/Jakarta: %v", err) + } + + sheetName, rows, parseIssues, err := parseManualInputFile(opts.FilePath, opts.Sheet, location) + if err != nil { + log.Fatalf("failed reading excel: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + resolver := dbFarmResolver{db: db} + + farmNameByID, err := resolver.ResolveActiveLayingFarms(ctx, collectProjectFlockIDs(rows)) + if err != nil { + log.Fatalf("failed validating project_flock_id against project_flocks: %v", err) + } + + issues := append([]validationIssue{}, parseIssues...) + issues = append(issues, buildMissingFarmIssues(rows, farmNameByID)...) + sortValidationIssues(issues) + + fmt.Printf("Mode: %s\n", modeLabel(opts.Apply)) + fmt.Printf("File: %s\n", opts.FilePath) + fmt.Printf("Sheet: %s\n", sheetName) + fmt.Printf("Rows parsed: %d\n", len(rows)) + fmt.Printf("Rows invalid: %d\n", len(issues)) + fmt.Println() + + if len(rows) > 0 { + printPlanRows(rows, farmNameByID) + fmt.Println() + } + + if len(issues) > 0 { + fmt.Println("Validation errors:") + for _, issue := range issues { + fmt.Printf("ERROR %s\n", issue.Error()) + } + fmt.Println() + fmt.Printf("Summary: planned=%d applied=0 failed=%d\n", len(rows), len(issues)) + os.Exit(1) + } + + if !opts.Apply { + fmt.Printf("Summary: planned=%d applied=0 failed=0\n", len(rows)) + return + } + + if len(rows) == 0 { + fmt.Println("Summary: planned=0 applied=0 failed=0") + return + } + + if err := applyIfRequested(ctx, true, dbTxRunner{db: db}, rows); err != nil { + log.Fatalf("apply failed: %v", err) + } + + for _, row := range rows { + fmt.Printf( + "DONE row=%d project_flock_id=%d cutover_date=%s\n", + row.RowNumber, + row.ProjectFlockID, + row.CutoverDate.In(location).Format(dateLayout), + ) + } + + fmt.Println() + fmt.Printf("Summary: planned=%d applied=%d failed=0\n", len(rows), len(rows)) +} + +func parseManualInputFile( + filePath string, + requestedSheet string, + location *time.Location, +) (string, []manualInputImportRow, []validationIssue, error) { + workbook, err := excelize.OpenFile(filePath) + if err != nil { + return "", nil, nil, err + } + defer func() { + _ = workbook.Close() + }() + + sheetName, err := resolveSheetName(workbook, requestedSheet) + if err != nil { + return "", nil, nil, err + } + + allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true}) + if err != nil { + return "", nil, nil, err + } + if len(allRows) == 0 { + return sheetName, nil, []validationIssue{ + {Field: "header", Message: "sheet is empty"}, + }, nil + } + + indexes, headerIssues := parseHeaderIndexes(allRows[0]) + if len(headerIssues) > 0 { + return sheetName, nil, headerIssues, nil + } + + rows := make([]manualInputImportRow, 0, len(allRows)-1) + issues := make([]validationIssue, 0) + seenProjectFlockIDs := make(map[uint]int) + + for idx := 1; idx < len(allRows); idx++ { + rowNumber := idx + 1 + rawRow := allRows[idx] + + if isRowEmpty(rawRow) { + continue + } + + parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, location, seenProjectFlockIDs) + if len(rowIssues) > 0 { + issues = append(issues, rowIssues...) + continue + } + + rows = append(rows, *parsed) + } + + if len(rows) == 0 && len(issues) == 0 { + issues = append(issues, validationIssue{ + Field: "rows", + Message: "no data rows found", + }) + } + + return sheetName, rows, issues, nil +} + +func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) { + if workbook == nil { + return "", fmt.Errorf("workbook is nil") + } + + sheets := workbook.GetSheetList() + if len(sheets) == 0 { + return "", fmt.Errorf("workbook has no sheets") + } + + if requestedSheet == "" { + return sheets[0], nil + } + + for _, sheet := range sheets { + if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) { + return sheet, nil + } + } + + return "", fmt.Errorf("sheet %q not found", requestedSheet) +} + +func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) { + indexes := headerIndexes{ + ProjectFlockID: -1, + TotalCost: -1, + CutoverDate: -1, + Note: -1, + } + issues := make([]validationIssue, 0) + + for idx, raw := range headerRow { + header := normalizeHeader(raw) + if header == "" { + continue + } + + switch header { + case "project_flock_id": + if indexes.ProjectFlockID >= 0 { + issues = append(issues, validationIssue{ + Field: "header", + Message: "duplicate header project_flock_id", + }) + } + indexes.ProjectFlockID = idx + case "total_cost": + if indexes.TotalCost >= 0 { + issues = append(issues, validationIssue{ + Field: "header", + Message: "duplicate header total_cost", + }) + } + indexes.TotalCost = idx + case "cutover_date": + if indexes.CutoverDate >= 0 { + issues = append(issues, validationIssue{ + Field: "header", + Message: "duplicate header cutover_date", + }) + } + indexes.CutoverDate = idx + case "note": + if indexes.Note >= 0 { + issues = append(issues, validationIssue{ + Field: "header", + Message: "duplicate header note", + }) + } + indexes.Note = idx + } + } + + if indexes.ProjectFlockID < 0 { + issues = append(issues, validationIssue{ + Field: "project_flock_id", + Message: "required header is missing", + }) + } + if indexes.TotalCost < 0 { + issues = append(issues, validationIssue{ + Field: "total_cost", + Message: "required header is missing", + }) + } + if indexes.CutoverDate < 0 { + issues = append(issues, validationIssue{ + Field: "cutover_date", + Message: "required header is missing", + }) + } + + return indexes, issues +} + +func parseDataRow( + rawRow []string, + rowNumber int, + indexes headerIndexes, + location *time.Location, + seenProjectFlockIDs map[uint]int, +) (*manualInputImportRow, []validationIssue) { + issues := make([]validationIssue, 0) + + projectFlockIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.ProjectFlockID)) + projectFlockID, err := parsePositiveUint(projectFlockIDRaw) + if err != nil { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "project_flock_id", + Message: err.Error(), + }) + } + + totalCostRaw := strings.TrimSpace(cellValue(rawRow, indexes.TotalCost)) + totalCost, err := parseNonNegativeFloat(totalCostRaw) + if err != nil { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "total_cost", + Message: err.Error(), + }) + } + + cutoverDateRaw := strings.TrimSpace(cellValue(rawRow, indexes.CutoverDate)) + cutoverDate, err := parseDateOnlyInLocation(cutoverDateRaw, location) + if err != nil { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "cutover_date", + Message: err.Error(), + }) + } + + var note *string + noteRaw := strings.TrimSpace(cellValue(rawRow, indexes.Note)) + if noteRaw != "" { + if len([]rune(noteRaw)) > 1000 { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "note", + Message: "must have at most 1000 characters", + }) + } else { + note = ¬eRaw + } + } + + if projectFlockID > 0 { + if previousRow, exists := seenProjectFlockIDs[projectFlockID]; exists { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "project_flock_id", + Message: fmt.Sprintf("duplicate value %d (already used in row %d)", projectFlockID, previousRow), + }) + } else { + seenProjectFlockIDs[projectFlockID] = rowNumber + } + } + + if len(issues) > 0 { + return nil, issues + } + + return &manualInputImportRow{ + RowNumber: rowNumber, + ProjectFlockID: projectFlockID, + TotalCost: totalCost, + CutoverDate: cutoverDate, + Note: note, + }, nil +} + +func parsePositiveUint(raw string) (uint, error) { + if raw == "" { + return 0, fmt.Errorf("is required") + } + + uintValue, err := strconv.ParseUint(raw, 10, 64) + if err == nil { + if uintValue == 0 { + return 0, fmt.Errorf("must be greater than 0") + } + return uint(uintValue), nil + } + + floatValue, floatErr := strconv.ParseFloat(raw, 64) + if floatErr != nil { + return 0, fmt.Errorf("must be a positive integer") + } + if floatValue <= 0 { + return 0, fmt.Errorf("must be greater than 0") + } + if floatValue != float64(uint(floatValue)) { + return 0, fmt.Errorf("must be a positive integer") + } + + return uint(floatValue), nil +} + +func parseNonNegativeFloat(raw string) (float64, error) { + if raw == "" { + return 0, fmt.Errorf("is required") + } + + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, fmt.Errorf("must be numeric") + } + if value < 0 { + return 0, fmt.Errorf("must be greater than or equal to 0") + } + + return value, nil +} + +func parseDateOnlyInLocation(raw string, location *time.Location) (time.Time, error) { + if raw == "" { + return time.Time{}, fmt.Errorf("is required") + } + value, err := time.ParseInLocation(dateLayout, raw, location) + if err != nil { + return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD") + } + return value, nil +} + +func isRowEmpty(row []string) bool { + for _, cell := range row { + if strings.TrimSpace(cell) != "" { + return false + } + } + return true +} + +func normalizeHeader(raw string) string { + return strings.ToLower(strings.TrimSpace(raw)) +} + +func cellValue(row []string, index int) string { + if index < 0 || index >= len(row) { + return "" + } + return row[index] +} + +func collectProjectFlockIDs(rows []manualInputImportRow) []uint { + ids := make([]uint, 0, len(rows)) + seen := make(map[uint]struct{}, len(rows)) + for _, row := range rows { + if row.ProjectFlockID == 0 { + continue + } + if _, exists := seen[row.ProjectFlockID]; exists { + continue + } + seen[row.ProjectFlockID] = struct{}{} + ids = append(ids, row.ProjectFlockID) + } + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + return ids +} + +func (r dbFarmResolver) ResolveActiveLayingFarms( + ctx context.Context, + projectFlockIDs []uint, +) (map[uint]string, error) { + result := make(map[uint]string) + if len(projectFlockIDs) == 0 { + return result, nil + } + + rows := make([]farmIdentityRow, 0, len(projectFlockIDs)) + if err := r.db.WithContext(ctx). + Table("project_flocks"). + Select("id, flock_name AS farm_name"). + Where("id IN ?", projectFlockIDs). + Where("deleted_at IS NULL"). + Where("category = ?", utils.ProjectFlockCategoryLaying). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ID] = row.FarmName + } + + return result, nil +} + +func buildMissingFarmIssues(rows []manualInputImportRow, farmNameByID map[uint]string) []validationIssue { + issues := make([]validationIssue, 0) + for _, row := range rows { + if _, exists := farmNameByID[row.ProjectFlockID]; exists { + continue + } + issues = append(issues, validationIssue{ + Row: row.RowNumber, + Field: "project_flock_id", + Message: fmt.Sprintf("value %d must reference an active LAYING project_flock", row.ProjectFlockID), + }) + } + return issues +} + +func printPlanRows(rows []manualInputImportRow, farmNameByID map[uint]string) { + for _, row := range rows { + farmName := farmNameByID[row.ProjectFlockID] + fmt.Printf( + "PLAN row=%d project_flock_id=%d farm_name=%q total_cost=%.3f cutover_date=%s note=%q\n", + row.RowNumber, + row.ProjectFlockID, + farmName, + row.TotalCost, + row.CutoverDate.Format(dateLayout), + derefString(row.Note), + ) + } +} + +func sortValidationIssues(issues []validationIssue) { + sort.Slice(issues, func(i, j int) bool { + if issues[i].Row == issues[j].Row { + if issues[i].Field == issues[j].Field { + return issues[i].Message < issues[j].Message + } + return issues[i].Field < issues[j].Field + } + return issues[i].Row < issues[j].Row + }) +} + +func applyIfRequested(ctx context.Context, apply bool, runner txRunner, rows []manualInputImportRow) error { + if !apply || len(rows) == 0 { + return nil + } + return applyImportRows(ctx, runner, rows) +} + +func applyImportRows(ctx context.Context, runner txRunner, rows []manualInputImportRow) error { + return runner.InTx(ctx, func(store manualInputStore) error { + for _, row := range rows { + payload := entity.FarmDepreciationManualInput{ + ProjectFlockId: row.ProjectFlockID, + TotalCost: row.TotalCost, + CutoverDate: row.CutoverDate, + Note: row.Note, + } + + if err := store.UpsertManualInput(ctx, &payload); err != nil { + return fmt.Errorf("row %d project_flock_id=%d upsert failed: %w", row.RowNumber, row.ProjectFlockID, err) + } + + if err := store.DeleteSnapshotsFromDate(ctx, row.CutoverDate, []uint{row.ProjectFlockID}); err != nil { + return fmt.Errorf("row %d project_flock_id=%d snapshot invalidation failed: %w", row.RowNumber, row.ProjectFlockID, err) + } + } + return nil + }) +} + +func (r dbTxRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repo := repportRepo.NewExpenseDepreciationRepository(tx) + store := expenseDepreciationStore{repo: repo} + return fn(store) + }) +} + +func (s expenseDepreciationStore) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error { + return s.repo.UpsertManualInput(ctx, row) +} + +func (s expenseDepreciationStore) DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error { + return s.repo.DeleteSnapshotsFromDate(ctx, fromDate, farmIDs) +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/cmd/import-farm-depreciation-manual-inputs/main_test.go b/cmd/import-farm-depreciation-manual-inputs/main_test.go new file mode 100644 index 00000000..0a2d8439 --- /dev/null +++ b/cmd/import-farm-depreciation-manual-inputs/main_test.go @@ -0,0 +1,563 @@ +package main + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/xuri/excelize/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +func TestParseManualInputFile_ValidSingleRow(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "12345.678", "2026-06-01", "manual seed"}, + }, + ) + + location := mustJakartaLocation(t) + sheet, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if sheet != "manual_inputs" { + t.Fatalf("expected selected sheet manual_inputs, got %q", sheet) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].ProjectFlockID != 101 { + t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID) + } + if rows[0].TotalCost != 12345.678 { + t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost) + } + if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" { + t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout)) + } + if rows[0].Note == nil || *rows[0].Note != "manual seed" { + t.Fatalf("expected note manual seed, got %+v", rows[0].Note) + } +} + +func TestParseManualInputFile_ValidMultiRow(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + {"102", "1300.5", "2026-06-02", "second"}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + if rows[0].Note != nil { + t.Fatalf("expected first row note nil, got %+v", rows[0].Note) + } + if rows[1].Note == nil || *rows[1].Note != "second" { + t.Fatalf("expected second row note second, got %+v", rows[1].Note) + } +} + +func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "totalcost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows)) + } + if !hasIssue(issues, 0, "total_cost", "required header is missing") { + t.Fatalf("expected missing total_cost header issue, got %+v", issues) + } +} + +func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"abc", "1200", "2026-06-01", ""}, + {"0", "1300", "2026-06-02", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") { + t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues) + } + if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") { + t.Fatalf("expected project_flock_id >0 issue, got %+v", issues) + } +} + +func TestParseManualInputFile_InvalidTotalCost(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "abc", "2026-06-01", ""}, + {"102", "-1", "2026-06-02", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "total_cost", "must be numeric") { + t.Fatalf("expected total_cost numeric issue, got %+v", issues) + } + if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") { + t.Fatalf("expected total_cost >=0 issue, got %+v", issues) + } +} + +func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "06-01-2026", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") { + t.Fatalf("expected cutover_date format issue, got %+v", issues) + } +} + +func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + {"101", "1300", "2026-06-02", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows)) + } + if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") { + t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues) + } +} + +func TestParseManualInputFile_NoteValidation(t *testing.T) { + longNote := strings.Repeat("a", 1001) + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + {"102", "1300", "2026-06-02", longNote}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected only first row valid, got %d", len(rows)) + } + if rows[0].Note != nil { + t.Fatalf("expected first row note nil, got %+v", rows[0].Note) + } + if !hasIssue(issues, 3, "note", "at most 1000 characters") { + t.Fatalf("expected note length issue, got %+v", issues) + } +} + +func TestApplyImportRows_Success(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{} + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + { + RowNumber: 3, + ProjectFlockID: 102, + TotalCost: 2000, + CutoverDate: mustDateInLocation(t, "2026-06-02", location), + }, + } + + err := applyImportRows(context.Background(), runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) + } + if len(runner.committedUpserts) != 2 { + t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts)) + } + if len(runner.committedInvalidations) != 2 { + t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations)) + } + if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 { + t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations) + } +} + +func TestApplyImportRows_RollbackOnError(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{ + failUpsertOnProjectFlockID: 102, + } + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + { + RowNumber: 3, + ProjectFlockID: 102, + TotalCost: 2000, + CutoverDate: mustDateInLocation(t, "2026-06-02", location), + }, + } + + err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatal("expected error due to upsert failure") + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) + } + if len(runner.committedUpserts) != 0 { + t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts)) + } + if len(runner.committedInvalidations) != 0 { + t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations)) + } +} + +func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{} + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + + err := applyIfRequested(context.Background(), false, runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 0 { + t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls) + } +} + +func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string { + t.Helper() + + f := excelize.NewFile() + defaultSheet := f.GetSheetName(f.GetActiveSheetIndex()) + if sheetName == "" { + sheetName = defaultSheet + } else if sheetName != defaultSheet { + f.SetSheetName(defaultSheet, sheetName) + } + + for idx, header := range headers { + cell, err := excelize.CoordinatesToCellName(idx+1, 1) + if err != nil { + t.Fatalf("failed resolving header cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, header); err != nil { + t.Fatalf("failed setting header cell: %v", err) + } + } + + for rowIdx, row := range rows { + for colIdx, value := range row { + cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) + if err != nil { + t.Fatalf("failed resolving data cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, value); err != nil { + t.Fatalf("failed setting data cell: %v", err) + } + } + } + + path := filepath.Join(t.TempDir(), "manual_inputs.xlsx") + if err := f.SaveAs(path); err != nil { + t.Fatalf("failed saving workbook: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("failed closing workbook: %v", err) + } + + return path +} + +func mustJakartaLocation(t *testing.T) *time.Location { + t.Helper() + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading Asia/Jakarta location: %v", err) + } + return location +} + +func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time { + t.Helper() + value, err := time.ParseInLocation(dateLayout, raw, location) + if err != nil { + t.Fatalf("failed parsing date %q: %v", raw, err) + } + return value +} + +func hasIssue(issues []validationIssue, row int, field, messageContains string) bool { + for _, issue := range issues { + if issue.Row != row { + continue + } + if issue.Field != field { + continue + } + if strings.Contains(issue.Message, messageContains) { + return true + } + } + return false +} + +type fakeInvalidation struct { + fromDate time.Time + farmIDs []uint +} + +type fakeManualInputStore struct { + failUpsertOnProjectFlockID uint + failDeleteOnProjectFlockID uint + upserts []entity.FarmDepreciationManualInput + invalidations []fakeInvalidation +} + +func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error { + if row == nil { + return nil + } + if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID { + return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId) + } + cloned := *row + s.upserts = append(s.upserts, cloned) + return nil +} + +func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error { + if s.failDeleteOnProjectFlockID > 0 { + for _, farmID := range farmIDs { + if farmID == s.failDeleteOnProjectFlockID { + return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID) + } + } + } + copiedFarmIDs := append([]uint{}, farmIDs...) + s.invalidations = append(s.invalidations, fakeInvalidation{ + fromDate: fromDate, + farmIDs: copiedFarmIDs, + }) + return nil +} + +type fakeTransactionRunner struct { + txCalls int + failUpsertOnProjectFlockID uint + failDeleteOnProjectFlockID uint + committedUpserts []entity.FarmDepreciationManualInput + committedInvalidations []fakeInvalidation +} + +func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error { + r.txCalls++ + + txStore := &fakeManualInputStore{ + failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID, + failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID, + } + + if err := fn(txStore); err != nil { + return err + } + + r.committedUpserts = append(r.committedUpserts, txStore.upserts...) + r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...) + return nil +} + +var _ txRunner = (*fakeTransactionRunner)(nil) +var _ manualInputStore = (*fakeManualInputStore)(nil) + +func TestBuildMissingFarmIssues(t *testing.T) { + location := mustJakartaLocation(t) + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + { + RowNumber: 3, + ProjectFlockID: 102, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + + issues := buildMissingFarmIssues(rows, map[uint]string{ + 101: "Farm A", + }) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %+v", issues) + } + if issues[0].Row != 3 || issues[0].Field != "project_flock_id" { + t.Fatalf("unexpected issue: %+v", issues[0]) + } +} + +func TestApplyImportRows_PropagatesDeleteError(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{ + failDeleteOnProjectFlockID: 101, + } + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + + err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatal("expected delete failure") + } + if !strings.Contains(err.Error(), "snapshot invalidation failed") { + t.Fatalf("expected snapshot invalidation error message, got %v", err) + } +} + +func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) { + workbook := excelize.NewFile() + defer func() { + _ = workbook.Close() + }() + + _, err := resolveSheetName(workbook, "unknown") + if err == nil { + t.Fatal("expected error when sheet is missing") + } +} + +func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) { + location := mustJakartaLocation(t) + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + runner := &errorTxRunner{err: errors.New("tx failed")} + + err := applyIfRequested(context.Background(), true, runner, rows) + if err == nil { + t.Fatal("expected transaction error") + } + if err.Error() != "tx failed" { + t.Fatalf("unexpected error: %v", err) + } +} + +type errorTxRunner struct { + err error +} + +func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error { + return r.err +} diff --git a/cmd/import-kandang-house-types/main.go b/cmd/import-kandang-house-types/main.go new file mode 100644 index 00000000..27e97ffb --- /dev/null +++ b/cmd/import-kandang-house-types/main.go @@ -0,0 +1,602 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type importOptions struct { + FilePath string + Sheet string + Apply bool +} + +type headerIndexes struct { + KandangID int + KandangName int + HouseType int +} + +type kandangHouseTypeImportRow struct { + RowNumber int + KandangID uint + KandangName string + HouseType string +} + +type validationIssue struct { + Row int + Field string + Message string +} + +func (i validationIssue) Error() string { + if i.Row > 0 { + return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message) + } + return fmt.Sprintf("field=%s message=%s", i.Field, i.Message) +} + +type kandangResolver interface { + ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) +} + +type dbKandangResolver struct { + db *gorm.DB +} + +type txRunner interface { + InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error +} + +type dbTxRunner struct { + db *gorm.DB +} + +type kandangHouseTypeStore interface { + UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) + NormalizeNullHouseType(ctx context.Context) (int64, error) +} + +type dbKandangHouseTypeStore struct { + db *gorm.DB +} + +type kandangIdentityRow struct { + ID uint `gorm:"column:id"` + Name string `gorm:"column:name"` +} + +type applyRowResult struct { + RowNumber int + KandangID uint + HouseType string + Changed bool +} + +func main() { + var opts importOptions + flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)") + flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)") + flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run") + flag.Parse() + + opts.FilePath = strings.TrimSpace(opts.FilePath) + opts.Sheet = strings.TrimSpace(opts.Sheet) + + if opts.FilePath == "" { + log.Fatal("--file is required") + } + + sheetName, rows, parseIssues, err := parseKandangHouseTypeFile(opts.FilePath, opts.Sheet) + if err != nil { + log.Fatalf("failed reading excel: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + resolver := dbKandangResolver{db: db} + + kandangNameByID, err := resolver.ResolveActiveKandangs(ctx, collectKandangIDs(rows)) + if err != nil { + log.Fatalf("failed validating kandang_id against kandangs: %v", err) + } + + issues := append([]validationIssue{}, parseIssues...) + issues = append(issues, buildMissingKandangIssues(rows, kandangNameByID)...) + issues = append(issues, buildNameMismatchIssues(rows, kandangNameByID)...) + sortValidationIssues(issues) + + fmt.Printf("Mode: %s\n", modeLabel(opts.Apply)) + fmt.Printf("File: %s\n", opts.FilePath) + fmt.Printf("Sheet: %s\n", sheetName) + fmt.Printf("Rows parsed: %d\n", len(rows)) + fmt.Printf("Rows invalid: %d\n", len(issues)) + fmt.Println() + + if len(rows) > 0 { + printPlanRows(rows, kandangNameByID) + fmt.Println() + } + + if len(issues) > 0 { + fmt.Println("Validation errors:") + for _, issue := range issues { + fmt.Printf("ERROR %s\n", issue.Error()) + } + fmt.Println() + fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=%d\n", len(rows), len(issues)) + os.Exit(1) + } + + if !opts.Apply { + fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=0\n", len(rows)) + return + } + + rowResults, normalizedCount, err := applyImportRows(ctx, dbTxRunner{db: db}, rows) + if err != nil { + log.Fatalf("apply failed: %v", err) + } + + for _, result := range rowResults { + fmt.Printf( + "DONE row=%d kandang_id=%d house_type=%s status=%s\n", + result.RowNumber, + result.KandangID, + result.HouseType, + applyStatus(result.Changed), + ) + } + + appliedCount := countChangedRows(rowResults) + fmt.Println() + fmt.Printf( + "Summary: planned=%d applied=%d normalized_null_to_open_house=%d failed=0\n", + len(rows), + appliedCount, + normalizedCount, + ) +} + +func parseKandangHouseTypeFile( + filePath string, + requestedSheet string, +) (string, []kandangHouseTypeImportRow, []validationIssue, error) { + workbook, err := excelize.OpenFile(filePath) + if err != nil { + return "", nil, nil, err + } + defer func() { + _ = workbook.Close() + }() + + sheetName, err := resolveSheetName(workbook, requestedSheet) + if err != nil { + return "", nil, nil, err + } + + allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true}) + if err != nil { + return "", nil, nil, err + } + if len(allRows) == 0 { + return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil + } + + indexes, headerIssues := parseHeaderIndexes(allRows[0]) + if len(headerIssues) > 0 { + return sheetName, nil, headerIssues, nil + } + + rows := make([]kandangHouseTypeImportRow, 0, len(allRows)-1) + issues := make([]validationIssue, 0) + seenKandangIDs := make(map[uint]int) + + for idx := 1; idx < len(allRows); idx++ { + rowNumber := idx + 1 + rawRow := allRows[idx] + + if isRowEmpty(rawRow) { + continue + } + + parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, seenKandangIDs) + if len(rowIssues) > 0 { + issues = append(issues, rowIssues...) + continue + } + + rows = append(rows, *parsed) + } + + if len(rows) == 0 && len(issues) == 0 { + issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"}) + } + + return sheetName, rows, issues, nil +} + +func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) { + if workbook == nil { + return "", fmt.Errorf("workbook is nil") + } + + sheets := workbook.GetSheetList() + if len(sheets) == 0 { + return "", fmt.Errorf("workbook has no sheets") + } + + if requestedSheet == "" { + return sheets[0], nil + } + + for _, sheet := range sheets { + if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) { + return sheet, nil + } + } + + return "", fmt.Errorf("sheet %q not found", requestedSheet) +} + +func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) { + indexes := headerIndexes{KandangID: -1, KandangName: -1, HouseType: -1} + issues := make([]validationIssue, 0) + + for idx, raw := range headerRow { + header := normalizeHeader(raw) + if header == "" { + continue + } + + switch header { + case "kandang_id": + if indexes.KandangID >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_id"}) + } + indexes.KandangID = idx + case "kandang_name": + if indexes.KandangName >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_name"}) + } + indexes.KandangName = idx + case "house_type", "type_house": + if indexes.HouseType >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header house_type"}) + } + indexes.HouseType = idx + } + } + + if indexes.KandangID < 0 { + issues = append(issues, validationIssue{Field: "kandang_id", Message: "required header is missing"}) + } + if indexes.KandangName < 0 { + issues = append(issues, validationIssue{Field: "kandang_name", Message: "required header is missing"}) + } + if indexes.HouseType < 0 { + issues = append(issues, validationIssue{Field: "house_type", Message: "required header is missing"}) + } + + return indexes, issues +} + +func parseDataRow( + rawRow []string, + rowNumber int, + indexes headerIndexes, + seenKandangIDs map[uint]int, +) (*kandangHouseTypeImportRow, []validationIssue) { + issues := make([]validationIssue, 0) + + kandangIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangID)) + kandangID, err := parsePositiveUint(kandangIDRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_id", Message: err.Error()}) + } + + kandangNameRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangName)) + if kandangNameRaw == "" { + issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_name", Message: "is required"}) + } + + houseTypeRaw := strings.TrimSpace(cellValue(rawRow, indexes.HouseType)) + houseType, err := normalizeHouseType(houseTypeRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "house_type", Message: err.Error()}) + } + + if kandangID > 0 { + if previousRow, exists := seenKandangIDs[kandangID]; exists { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "kandang_id", + Message: fmt.Sprintf("duplicate value %d (already used in row %d)", kandangID, previousRow), + }) + } else { + seenKandangIDs[kandangID] = rowNumber + } + } + + if len(issues) > 0 { + return nil, issues + } + + return &kandangHouseTypeImportRow{ + RowNumber: rowNumber, + KandangID: kandangID, + KandangName: kandangNameRaw, + HouseType: houseType, + }, nil +} + +func normalizeHouseType(raw string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if normalized == "" { + return string(utils.HouseTypeOpenHouse), nil + } + + switch normalized { + case string(utils.HouseTypeOpenHouse), string(utils.HouseTypeCloseHouse): + return normalized, nil + default: + return "", fmt.Errorf("must be one of: open_house, close_house (or empty for default open_house)") + } +} + +func parsePositiveUint(raw string) (uint, error) { + if raw == "" { + return 0, fmt.Errorf("is required") + } + + uintValue, err := strconv.ParseUint(raw, 10, 64) + if err == nil { + if uintValue == 0 { + return 0, fmt.Errorf("must be greater than 0") + } + return uint(uintValue), nil + } + + floatValue, floatErr := strconv.ParseFloat(raw, 64) + if floatErr != nil { + return 0, fmt.Errorf("must be a positive integer") + } + if floatValue <= 0 { + return 0, fmt.Errorf("must be greater than 0") + } + if floatValue != float64(uint(floatValue)) { + return 0, fmt.Errorf("must be a positive integer") + } + + return uint(floatValue), nil +} + +func isRowEmpty(row []string) bool { + for _, cell := range row { + if strings.TrimSpace(cell) != "" { + return false + } + } + return true +} + +func normalizeHeader(raw string) string { + return strings.ToLower(strings.TrimSpace(raw)) +} + +func cellValue(row []string, index int) string { + if index < 0 || index >= len(row) { + return "" + } + return row[index] +} + +func collectKandangIDs(rows []kandangHouseTypeImportRow) []uint { + ids := make([]uint, 0, len(rows)) + seen := make(map[uint]struct{}, len(rows)) + for _, row := range rows { + if row.KandangID == 0 { + continue + } + if _, exists := seen[row.KandangID]; exists { + continue + } + seen[row.KandangID] = struct{}{} + ids = append(ids, row.KandangID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func (r dbKandangResolver) ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) { + result := make(map[uint]string) + if len(kandangIDs) == 0 { + return result, nil + } + + rows := make([]kandangIdentityRow, 0, len(kandangIDs)) + if err := r.db.WithContext(ctx). + Table("kandangs"). + Select("id, name"). + Where("id IN ?", kandangIDs). + Where("deleted_at IS NULL"). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ID] = row.Name + } + + return result, nil +} + +func buildMissingKandangIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue { + issues := make([]validationIssue, 0) + for _, row := range rows { + if _, exists := kandangNameByID[row.KandangID]; exists { + continue + } + issues = append(issues, validationIssue{ + Row: row.RowNumber, + Field: "kandang_id", + Message: fmt.Sprintf("value %d must reference an active kandang", row.KandangID), + }) + } + return issues +} + +func buildNameMismatchIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue { + issues := make([]validationIssue, 0) + for _, row := range rows { + dbName, exists := kandangNameByID[row.KandangID] + if !exists { + continue + } + if strings.EqualFold(strings.TrimSpace(row.KandangName), strings.TrimSpace(dbName)) { + continue + } + + issues = append(issues, validationIssue{ + Row: row.RowNumber, + Field: "kandang_name", + Message: fmt.Sprintf("value %q does not match kandang_id %d name %q", row.KandangName, row.KandangID, dbName), + }) + } + return issues +} + +func printPlanRows(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) { + for _, row := range rows { + fmt.Printf( + "PLAN row=%d kandang_id=%d kandang_name_file=%q kandang_name_db=%q house_type=%q\n", + row.RowNumber, + row.KandangID, + row.KandangName, + kandangNameByID[row.KandangID], + row.HouseType, + ) + } +} + +func sortValidationIssues(issues []validationIssue) { + sort.Slice(issues, func(i, j int) bool { + if issues[i].Row == issues[j].Row { + if issues[i].Field == issues[j].Field { + return issues[i].Message < issues[j].Message + } + return issues[i].Field < issues[j].Field + } + return issues[i].Row < issues[j].Row + }) +} + +func applyImportRows( + ctx context.Context, + runner txRunner, + rows []kandangHouseTypeImportRow, +) ([]applyRowResult, int64, error) { + results := make([]applyRowResult, 0, len(rows)) + normalizedNullCount := int64(0) + + err := runner.InTx(ctx, func(store kandangHouseTypeStore) error { + for _, row := range rows { + changed, err := store.UpdateKandangHouseType(ctx, row.KandangID, row.HouseType) + if err != nil { + return fmt.Errorf("row %d kandang_id=%d update failed: %w", row.RowNumber, row.KandangID, err) + } + + results = append(results, applyRowResult{ + RowNumber: row.RowNumber, + KandangID: row.KandangID, + HouseType: row.HouseType, + Changed: changed, + }) + } + + normalized, err := store.NormalizeNullHouseType(ctx) + if err != nil { + return fmt.Errorf("normalize null house_type to open_house failed: %w", err) + } + normalizedNullCount = normalized + + return nil + }) + if err != nil { + return nil, 0, err + } + + return results, normalizedNullCount, nil +} + +func (r dbTxRunner) InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return fn(dbKandangHouseTypeStore{db: tx}) + }) +} + +func (s dbKandangHouseTypeStore) UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) { + result := s.db.WithContext(ctx).Exec(` + UPDATE kandangs + SET house_type = ?::house_type_enum, + updated_at = NOW() + WHERE id = ? + AND deleted_at IS NULL + AND house_type IS DISTINCT FROM ?::house_type_enum + `, houseType, kandangID, houseType) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +func (s dbKandangHouseTypeStore) NormalizeNullHouseType(ctx context.Context) (int64, error) { + result := s.db.WithContext(ctx).Exec(` + UPDATE kandangs + SET house_type = 'open_house'::house_type_enum, + updated_at = NOW() + WHERE deleted_at IS NULL + AND house_type IS NULL + `) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func applyStatus(changed bool) string { + if changed { + return "UPDATED" + } + return "UNCHANGED" +} + +func countChangedRows(results []applyRowResult) int { + count := 0 + for _, item := range results { + if item.Changed { + count++ + } + } + return count +} diff --git a/cmd/import-kandang-house-types/main_test.go b/cmd/import-kandang-house-types/main_test.go new file mode 100644 index 00000000..bc733ed9 --- /dev/null +++ b/cmd/import-kandang-house-types/main_test.go @@ -0,0 +1,280 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestParseKandangHouseTypeFile_ValidSingleRowAndDefaultHouseType(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "house_type"}, + [][]string{{"101", "Kandang A1", ""}}, + ) + + sheet, rows, issues, err := parseKandangHouseTypeFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if sheet != "kandang_house_type" { + t.Fatalf("expected sheet kandang_house_type, got %q", sheet) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].KandangID != 101 { + t.Fatalf("expected kandang_id 101, got %d", rows[0].KandangID) + } + if rows[0].KandangName != "Kandang A1" { + t.Fatalf("expected kandang_name Kandang A1, got %q", rows[0].KandangName) + } + if rows[0].HouseType != "open_house" { + t.Fatalf("expected default house_type open_house, got %q", rows[0].HouseType) + } +} + +func TestParseKandangHouseTypeFile_TypeHouseHeaderAlias(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "type_house"}, + [][]string{{"101", "Kandang A1", "close_house"}}, + ) + + _, rows, issues, err := parseKandangHouseTypeFile(filePath, "kandang_house_type") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 || rows[0].HouseType != "close_house" { + t.Fatalf("expected parsed close_house row, got %+v", rows) + } +} + +func TestParseKandangHouseTypeFile_InvalidHouseType(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "house_type"}, + [][]string{{"101", "Kandang A1", "semi_house"}}, + ) + + _, rows, issues, err := parseKandangHouseTypeFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "house_type", "must be one of") { + t.Fatalf("expected invalid house_type issue, got %+v", issues) + } +} + +func TestParseKandangHouseTypeFile_DuplicateKandangID(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "house_type"}, + [][]string{ + {"101", "Kandang A1", "open_house"}, + {"101", "Kandang A2", "close_house"}, + }, + ) + + _, rows, issues, err := parseKandangHouseTypeFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected first row valid and second invalid, got %d", len(rows)) + } + if !hasIssue(issues, 3, "kandang_id", "duplicate value 101") { + t.Fatalf("expected duplicate kandang_id issue, got %+v", issues) + } +} + +func TestBuildNameMismatchIssues(t *testing.T) { + rows := []kandangHouseTypeImportRow{{ + RowNumber: 2, + KandangID: 10, + KandangName: "Kandang Salah", + HouseType: "open_house", + }} + + issues := buildNameMismatchIssues(rows, map[uint]string{10: "Kandang Benar"}) + if !hasIssue(issues, 2, "kandang_name", "does not match") { + t.Fatalf("expected name mismatch issue, got %+v", issues) + } +} + +func TestApplyImportRows_Success(t *testing.T) { + store := &fakeStore{ + changedByID: map[uint]bool{101: true, 102: false}, + normalizeResult: 3, + } + runner := &fakeTransactionRunner{store: store} + + rows := []kandangHouseTypeImportRow{ + {RowNumber: 2, KandangID: 101, HouseType: "open_house"}, + {RowNumber: 3, KandangID: 102, HouseType: "close_house"}, + } + + results, normalized, err := applyImportRows(context.Background(), runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 tx call, got %d", runner.txCalls) + } + if len(results) != 2 { + t.Fatalf("expected 2 row results, got %d", len(results)) + } + if normalized != 3 { + t.Fatalf("expected normalized count 3, got %d", normalized) + } + if !results[0].Changed || results[1].Changed { + t.Fatalf("unexpected changed flags: %+v", results) + } + if len(store.updateCalls) != 2 { + t.Fatalf("expected 2 update calls, got %d", len(store.updateCalls)) + } +} + +func TestApplyImportRows_FailOnUpdate(t *testing.T) { + store := &fakeStore{ + updateErrByID: map[uint]error{101: errors.New("boom")}, + } + runner := &fakeTransactionRunner{store: store} + + rows := []kandangHouseTypeImportRow{{RowNumber: 2, KandangID: 101, HouseType: "open_house"}} + + _, _, err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), "update failed") { + t.Fatalf("expected update failed error, got %v", err) + } +} + +func TestCountChangedRows(t *testing.T) { + count := countChangedRows([]applyRowResult{{Changed: true}, {Changed: false}, {Changed: true}}) + if count != 2 { + t.Fatalf("expected 2 changed rows, got %d", count) + } +} + +type fakeTransactionRunner struct { + store *fakeStore + txCalls int +} + +func (f *fakeTransactionRunner) InTx(_ context.Context, fn func(store kandangHouseTypeStore) error) error { + f.txCalls++ + return fn(f.store) +} + +type updateCall struct { + kandangID uint + houseType string +} + +type fakeStore struct { + updateCalls []updateCall + changedByID map[uint]bool + updateErrByID map[uint]error + normalizeResult int64 + normalizeErr error +} + +func (f *fakeStore) UpdateKandangHouseType(_ context.Context, kandangID uint, houseType string) (bool, error) { + f.updateCalls = append(f.updateCalls, updateCall{kandangID: kandangID, houseType: houseType}) + if err, exists := f.updateErrByID[kandangID]; exists { + return false, err + } + if changed, exists := f.changedByID[kandangID]; exists { + return changed, nil + } + return true, nil +} + +func (f *fakeStore) NormalizeNullHouseType(_ context.Context) (int64, error) { + if f.normalizeErr != nil { + return 0, f.normalizeErr + } + return f.normalizeResult, nil +} + +func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string { + t.Helper() + + f := excelize.NewFile() + if sheetName == "" { + sheetName = "Sheet1" + } + defaultSheet := f.GetSheetName(0) + if defaultSheet != sheetName { + idx, err := f.NewSheet(sheetName) + if err != nil { + t.Fatalf("failed creating sheet: %v", err) + } + f.SetActiveSheet(idx) + _ = f.DeleteSheet(defaultSheet) + } + + for idx, header := range headers { + cell, err := excelize.CoordinatesToCellName(idx+1, 1) + if err != nil { + t.Fatalf("failed computing header cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, header); err != nil { + t.Fatalf("failed setting header cell: %v", err) + } + } + + for rowIdx, row := range rows { + for colIdx, value := range row { + cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) + if err != nil { + t.Fatalf("failed computing row cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, value); err != nil { + t.Fatalf("failed setting row cell: %v", err) + } + } + } + + path := filepath.Join(t.TempDir(), "kandang_house_type.xlsx") + if err := f.SaveAs(path); err != nil { + t.Fatalf("failed saving workbook: %v", err) + } + + return path +} + +func hasIssue(issues []validationIssue, row int, field string, contains string) bool { + for _, issue := range issues { + if issue.Row != row { + continue + } + if issue.Field != field { + continue + } + if strings.Contains(issue.Message, contains) { + return true + } + } + return false +} diff --git a/docs/farm_depreciation_manual_inputs_import.md b/docs/farm_depreciation_manual_inputs_import.md new file mode 100644 index 00000000..2198bfdd --- /dev/null +++ b/docs/farm_depreciation_manual_inputs_import.md @@ -0,0 +1,76 @@ +# Farm Depreciation Manual Inputs Import + +Command ini dipakai untuk bulk import data ke tabel `farm_depreciation_manual_inputs` dari file Excel (`.xlsx`). + +## Command + +```bash +go run ./cmd/import-farm-depreciation-manual-inputs --file [--sheet ] [--apply] +``` + +## Flags + +- `--file` (required): path file `.xlsx`. +- `--sheet` (optional): nama sheet. Jika tidak diisi, command pakai sheet pertama. +- `--apply` (optional): default `false` (dry-run). Jika `true`, command menulis ke database. + +## Mode + +- Dry-run (default): + - parsing dan validasi semua baris. + - validasi `project_flock_id` terhadap farm aktif kategori `LAYING`. + - menampilkan `PLAN` + daftar error. + - tidak menulis data. + +- Apply (`--apply`): + - semua validasi tetap dijalankan dulu. + - jika ada 1 error, proses dihentikan. + - jika valid, upsert dijalankan dalam 1 transaksi (fail-fast). + - setelah upsert, snapshot di `farm_depreciation_snapshots` dihapus mulai `cutover_date` untuk `project_flock_id` terkait. + +## Format Excel + +Template tersedia di: + +- `docs/templates/farm_depreciation_manual_inputs.xlsx` + +Header wajib ada di baris 1 (case-insensitive, trim-spaces): + +- `project_flock_id` (required, integer > 0) +- `total_cost` (required, numeric >= 0) +- `cutover_date` (required, format `YYYY-MM-DD`) +- `note` (optional, max 1000 karakter) + +Catatan: + +- Dalam 1 file tidak boleh ada duplikat `project_flock_id`. +- `project_flock_id` harus mengarah ke `project_flocks` yang `deleted_at IS NULL` dan `category = LAYING`. + +## Contoh + +Dry-run: + +```bash +env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \ +go run ./cmd/import-farm-depreciation-manual-inputs \ + --file docs/templates/farm_depreciation_manual_inputs.xlsx +``` + +Apply: + +```bash +env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \ +go run ./cmd/import-farm-depreciation-manual-inputs \ + --file /path/to/farm_depreciation_manual_inputs.xlsx \ + --sheet manual_inputs \ + --apply +``` + +## Error Umum + +- `required header is missing`: header wajib tidak ditemukan. +- `must be a positive integer`: `project_flock_id` bukan integer valid. +- `must be greater than or equal to 0`: `total_cost` negatif. +- `must follow format YYYY-MM-DD`: `cutover_date` tidak sesuai format. +- `duplicate value ...`: `project_flock_id` duplikat dalam file yang sama. +- `must reference an active LAYING project_flock`: farm tidak valid untuk import ini. diff --git a/docs/templates/farm_depreciation_manual_inputs.xlsx b/docs/templates/farm_depreciation_manual_inputs.xlsx new file mode 100644 index 00000000..c26db926 Binary files /dev/null and b/docs/templates/farm_depreciation_manual_inputs.xlsx differ diff --git a/docs/templates/kandang_house_type.xlsx b/docs/templates/kandang_house_type.xlsx new file mode 100644 index 00000000..24c7b045 Binary files /dev/null and b/docs/templates/kandang_house_type.xlsx differ diff --git a/docs/templates/~$farm_depreciation_manual_inputs.xlsx b/docs/templates/~$farm_depreciation_manual_inputs.xlsx new file mode 100644 index 00000000..5a932052 Binary files /dev/null and b/docs/templates/~$farm_depreciation_manual_inputs.xlsx differ diff --git a/docs/templates/~$kandang_house_type.xlsx b/docs/templates/~$kandang_house_type.xlsx new file mode 100644 index 00000000..5a932052 Binary files /dev/null and b/docs/templates/~$kandang_house_type.xlsx differ diff --git a/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.down.sql b/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.down.sql similarity index 100% rename from internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.down.sql rename to internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.down.sql diff --git a/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.up.sql b/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.up.sql similarity index 100% rename from internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.up.sql rename to internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.up.sql diff --git a/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.down.sql b/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.down.sql similarity index 100% rename from internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.down.sql rename to internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.down.sql diff --git a/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.up.sql b/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.up.sql similarity index 100% rename from internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.up.sql rename to internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.up.sql diff --git a/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql b/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..down.sql similarity index 100% rename from internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql rename to internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..down.sql diff --git a/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql b/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..up.sql similarity index 100% rename from internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql rename to internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..up.sql