From 45bed3b76597586dd7e46152911ee4eb4c726cd5 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 19 Apr 2026 21:13:48 +0700 Subject: [PATCH] add adjust migration --- .../main.go | 632 ++++++++++++++++++ .../main_test.go | 563 ++++++++++++++++ cmd/import-kandang-house-types/main.go | 602 +++++++++++++++++ cmd/import-kandang-house-types/main_test.go | 280 ++++++++ .../farm_depreciation_manual_inputs_import.md | 76 +++ .../farm_depreciation_manual_inputs.xlsx | Bin 0 -> 8668 bytes docs/templates/kandang_house_type.xlsx | Bin 0 -> 31880 bytes .../~$farm_depreciation_manual_inputs.xlsx | Bin 0 -> 165 bytes docs/templates/~$kandang_house_type.xlsx | Bin 0 -> 165 bytes ...eate_farm_depreciation_snapshots.down.sql} | 0 ...create_farm_depreciation_snapshots.up.sql} | 0 ..._farm_depreciation_manual_inputs.down.sql} | 0 ...te_farm_depreciation_manual_inputs.up.sql} | 0 ...farm_depreciation_manual_inputs..down.sql} | 0 ...o_farm_depreciation_manual_inputs..up.sql} | 0 15 files changed, 2153 insertions(+) create mode 100644 cmd/import-farm-depreciation-manual-inputs/main.go create mode 100644 cmd/import-farm-depreciation-manual-inputs/main_test.go create mode 100644 cmd/import-kandang-house-types/main.go create mode 100644 cmd/import-kandang-house-types/main_test.go create mode 100644 docs/farm_depreciation_manual_inputs_import.md create mode 100644 docs/templates/farm_depreciation_manual_inputs.xlsx create mode 100644 docs/templates/kandang_house_type.xlsx create mode 100644 docs/templates/~$farm_depreciation_manual_inputs.xlsx create mode 100644 docs/templates/~$kandang_house_type.xlsx rename internal/database/migrations/{20260416090000_create_farm_depreciation_snapshots.down.sql => 20260419134846_create_farm_depreciation_snapshots.down.sql} (100%) rename internal/database/migrations/{20260416090000_create_farm_depreciation_snapshots.up.sql => 20260419134846_create_farm_depreciation_snapshots.up.sql} (100%) rename internal/database/migrations/{20260417110000_create_farm_depreciation_manual_inputs.down.sql => 20260419135003_create_farm_depreciation_manual_inputs.down.sql} (100%) rename internal/database/migrations/{20260417110000_create_farm_depreciation_manual_inputs.up.sql => 20260419135003_create_farm_depreciation_manual_inputs.up.sql} (100%) rename internal/database/migrations/{20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql => 20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..down.sql} (100%) rename internal/database/migrations/{20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql => 20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..up.sql} (100%) 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 0000000000000000000000000000000000000000..c26db926077a229f1fd1703e74bbe34ddf2aa53f GIT binary patch literal 8668 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 E2O~7?d;kCd literal 0 HcmV?d00001 diff --git a/docs/templates/~$farm_depreciation_manual_inputs.xlsx b/docs/templates/~$farm_depreciation_manual_inputs.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5a932052db2a5d1e1d32a453f59be330b8becc3b GIT binary patch literal 165 zcmWgj%}g%JFV0UZQSeVo%S=vH2rW)6QXm9G8GIQs8Il=_81fm4fjEt!gh7G9A4sQx R#Z!U2P@qgIP=x};5CA3W7%cz* literal 0 HcmV?d00001 diff --git a/docs/templates/~$kandang_house_type.xlsx b/docs/templates/~$kandang_house_type.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5a932052db2a5d1e1d32a453f59be330b8becc3b GIT binary patch literal 165 zcmWgj%}g%JFV0UZQSeVo%S=vH2rW)6QXm9G8GIQs8Il=_81fm4fjEt!gh7G9A4sQx R#Z!U2P@qgIP=x};5CA3W7%cz* literal 0 HcmV?d00001 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