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 }