// Command seed-house-depreciation-standards membaca kurva depresiasi per-day // dari file Excel untuk SATU project flock LAYING, lalu meng-generate file // migration {up,down}.sql yang menyisipkan baris house_depreciation_standards // khusus flock tersebut. // // Hanya multiplication_percentage yang di-override; house_type & standard_week // diwarisi dari baris global (project_flock_id IS NULL) untuk hari yang sama, // dan depreciation_percent diturunkan = (1 - multiplication_percentage) * 100. // // Jalankan lokal: dry-run mencetak SQL ke stdout, -apply menulis file migration // untuk di-review & di-commit. Tidak ada API yang di-hit di production — data // masuk lewat `make migrate-up` saat deploy. // // go run ./cmd/seed-house-depreciation-standards -project-flock-id=52 -file=curve.xlsx # dry-run // go run ./cmd/seed-house-depreciation-standards -project-flock-id=52 -file=curve.xlsx -apply # tulis migration package main import ( "context" "flag" "fmt" "log" "os" "path/filepath" "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" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) const ( dateLayout = "2006-01-02" timestampLayout = "20060102150405" defaultMigrions = "internal/database/migrations" headerDay = "day" headerMultiplier = "multiplication_percentage" ) type options struct { ProjectFlockID uint FilePath string Sheet string EffectiveDate string HouseType string OutDir string Apply bool } type curveRow struct { RowNumber int Day int Mult float64 } type validationIssue struct { Row int Field string Message string } func (i validationIssue) String() 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) } func main() { var opts options flag.UintVar(&opts.ProjectFlockID, "project-flock-id", 0, "Target LAYING project_flock id (required)") flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file with columns day, multiplication_percentage (required)") flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)") flag.StringVar(&opts.EffectiveDate, "effective-date", "", "effective_date for the seeded rows (YYYY-MM-DD, default: today)") flag.StringVar(&opts.HouseType, "house-type", "", "Override house_type (open_house|close_house). Default: derived from the flock's kandangs") flag.StringVar(&opts.OutDir, "out-dir", defaultMigrions, "Directory to write the migration files") flag.BoolVar(&opts.Apply, "apply", false, "Write the migration files. If false, print SQL to stdout (dry-run)") flag.Parse() opts.FilePath = strings.TrimSpace(opts.FilePath) opts.Sheet = strings.TrimSpace(opts.Sheet) opts.OutDir = strings.TrimSpace(opts.OutDir) if opts.ProjectFlockID == 0 { log.Fatal("--project-flock-id is required and must be greater than 0") } 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) } effectiveDate, err := resolveEffectiveDate(opts.EffectiveDate, location) if err != nil { log.Fatalf("invalid --effective-date: %v", err) } opts.EffectiveDate = effectiveDate.Format(dateLayout) sheetName, curve, parseIssues, err := parseCurveFile(opts.FilePath, opts.Sheet) if err != nil { log.Fatalf("failed reading excel: %v", err) } ctx := context.Background() db := database.Connect(config.DBHost, config.DBName) if err := assertActiveLayingFlock(ctx, db, opts.ProjectFlockID); err != nil { log.Fatalf("project_flock validation failed: %v", err) } houseType, err := resolveHouseType(ctx, db, opts.ProjectFlockID, opts.HouseType) if err != nil { log.Fatalf("house_type resolution failed: %v", err) } issues := append([]validationIssue{}, parseIssues...) if len(curve) > 0 { globalDays, err := globalDaysForHouseType(ctx, db, houseType) if err != nil { log.Fatalf("failed loading global standard days: %v", err) } issues = append(issues, buildDayCoverageIssues(curve, globalDays, houseType)...) } 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("Project flock id: %d\n", opts.ProjectFlockID) fmt.Printf("House type: %s\n", houseType) fmt.Printf("Effective date: %s\n", opts.EffectiveDate) fmt.Printf("Curve rows parsed: %d\n", len(curve)) if len(curve) > 0 { minDay, maxDay := dayRange(curve) fmt.Printf("Day range: %d..%d\n", minDay, maxDay) } fmt.Printf("Validation errors: %d\n", len(issues)) fmt.Println() if len(issues) > 0 { fmt.Println("Validation errors:") for _, issue := range issues { fmt.Printf("ERROR %s\n", issue.String()) } os.Exit(1) } upSQL := buildUpSQL(opts, houseType, curve) downSQL := buildDownSQL(opts) prefix := time.Now().In(location).Format(timestampLayout) upName := fmt.Sprintf("%s_seed_house_depreciation_flock_%d.up.sql", prefix, opts.ProjectFlockID) downName := fmt.Sprintf("%s_seed_house_depreciation_flock_%d.down.sql", prefix, opts.ProjectFlockID) if !opts.Apply { fmt.Printf("--- %s ---\n%s\n", upName, upSQL) fmt.Printf("--- %s ---\n%s\n", downName, downSQL) fmt.Printf("Dry-run: would write 2 files to %s. Re-run with -apply to create them.\n", opts.OutDir) return } upPath := filepath.Join(opts.OutDir, upName) downPath := filepath.Join(opts.OutDir, downName) if err := os.WriteFile(upPath, []byte(upSQL), 0o644); err != nil { log.Fatalf("failed writing %s: %v", upPath, err) } if err := os.WriteFile(downPath, []byte(downSQL), 0o644); err != nil { log.Fatalf("failed writing %s: %v", downPath, err) } fmt.Printf("WROTE %s\n", upPath) fmt.Printf("WROTE %s\n", downPath) fmt.Println("Review the SQL, commit it, then deploy runs `make migrate-up`.") } func resolveEffectiveDate(raw string, location *time.Location) (time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { now := time.Now().In(location) return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location), nil } parsed, err := time.ParseInLocation(dateLayout, raw, location) if err != nil { return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD") } return parsed, nil } func parseCurveFile(filePath, requestedSheet string) (string, []curveRow, []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 } dayIdx, multIdx, headerIssues := parseHeaderIndexes(allRows[0]) if len(headerIssues) > 0 { return sheetName, nil, headerIssues, nil } rows := make([]curveRow, 0, len(allRows)-1) issues := make([]validationIssue, 0) seenDays := make(map[int]int) for idx := 1; idx < len(allRows); idx++ { rowNumber := idx + 1 rawRow := allRows[idx] if isRowEmpty(rawRow) { continue } parsed, rowIssues := parseCurveRow(rawRow, rowNumber, dayIdx, multIdx, seenDays) 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"}) } sort.Slice(rows, func(i, j int) bool { return rows[i].Day < rows[j].Day }) return sheetName, rows, issues, nil } func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) { sheets := workbook.GetSheetList() if len(sheets) == 0 { return "", fmt.Errorf("workbook has no sheets") } if strings.TrimSpace(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) (int, int, []validationIssue) { dayIdx, multIdx := -1, -1 issues := make([]validationIssue, 0) for idx, raw := range headerRow { switch normalizeHeader(raw) { case headerDay: if dayIdx >= 0 { issues = append(issues, validationIssue{Field: "header", Message: "duplicate header day"}) } dayIdx = idx case headerMultiplier: if multIdx >= 0 { issues = append(issues, validationIssue{Field: "header", Message: "duplicate header multiplication_percentage"}) } multIdx = idx } } if dayIdx < 0 { issues = append(issues, validationIssue{Field: headerDay, Message: "required header is missing"}) } if multIdx < 0 { issues = append(issues, validationIssue{Field: headerMultiplier, Message: "required header is missing"}) } return dayIdx, multIdx, issues } func parseCurveRow(rawRow []string, rowNumber, dayIdx, multIdx int, seenDays map[int]int) (*curveRow, []validationIssue) { issues := make([]validationIssue, 0) day, err := parsePositiveInt(strings.TrimSpace(cellValue(rawRow, dayIdx))) if err != nil { issues = append(issues, validationIssue{Row: rowNumber, Field: headerDay, Message: err.Error()}) } mult, err := parseMultiplication(strings.TrimSpace(cellValue(rawRow, multIdx))) if err != nil { issues = append(issues, validationIssue{Row: rowNumber, Field: headerMultiplier, Message: err.Error()}) } if day > 0 { if prev, exists := seenDays[day]; exists { issues = append(issues, validationIssue{Row: rowNumber, Field: headerDay, Message: fmt.Sprintf("duplicate day %d (already used in row %d)", day, prev)}) } else { seenDays[day] = rowNumber } } if len(issues) > 0 { return nil, issues } return &curveRow{RowNumber: rowNumber, Day: day, Mult: mult}, nil } func parsePositiveInt(raw string) (int, error) { if raw == "" { return 0, fmt.Errorf("is required") } value, err := strconv.Atoi(raw) if err != nil { floatValue, floatErr := strconv.ParseFloat(raw, 64) if floatErr != nil || floatValue != float64(int(floatValue)) { return 0, fmt.Errorf("must be a positive integer") } value = int(floatValue) } if value < 1 { return 0, fmt.Errorf("must be greater than or equal to 1") } return value, nil } func parseMultiplication(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 || value > 1 { return 0, fmt.Errorf("must be greater than 0 and at most 1") } return value, nil } func buildDayCoverageIssues(curve []curveRow, globalDays map[int]bool, houseType string) []validationIssue { issues := make([]validationIssue, 0) for _, row := range curve { if !globalDays[row.Day] { issues = append(issues, validationIssue{ Row: row.RowNumber, Field: headerDay, Message: fmt.Sprintf("day %d has no global standard row for house_type=%s (cannot inherit standard_week)", row.Day, houseType), }) } } return issues } func buildUpSQL(opts options, houseType string, curve []curveRow) string { var b strings.Builder fmt.Fprintf(&b, "-- Kurva depresiasi khusus project_flock_id=%d (house_type=%s, effective_date=%s).\n", opts.ProjectFlockID, houseType, opts.EffectiveDate) b.WriteString("-- Override hanya multiplication_percentage; house_type & standard_week diwarisi dari baris global.\n") b.WriteString("-- depreciation_percent diturunkan = (1 - multiplication_percentage) * 100.\n") b.WriteString("INSERT INTO house_depreciation_standards\n") b.WriteString(" (project_flock_id, house_type, day, effective_date,\n") b.WriteString(" multiplication_percentage, depreciation_percent, standard_week, name)\n") b.WriteString("SELECT\n") fmt.Fprintf(&b, " %d, g.house_type, g.day, DATE '%s',\n", opts.ProjectFlockID, opts.EffectiveDate) b.WriteString(" v.mult, (1 - v.mult) * 100, g.standard_week,\n") fmt.Fprintf(&b, " 'Custom depreciation flock %d (eff %s)'\n", opts.ProjectFlockID, opts.EffectiveDate) b.WriteString("FROM (VALUES\n") b.WriteString(formatValuesTuples(curve)) b.WriteString("\n) AS v(day, mult)\n") b.WriteString("JOIN LATERAL (\n") b.WriteString(" SELECT DISTINCT ON (day) house_type, day, standard_week\n") b.WriteString(" FROM house_depreciation_standards\n") b.WriteString(" WHERE project_flock_id IS NULL\n") fmt.Fprintf(&b, " AND house_type = '%s'::house_type_enum\n", houseType) b.WriteString(" AND day = v.day\n") b.WriteString(" ORDER BY day, effective_date DESC NULLS LAST\n") b.WriteString(") g ON TRUE;\n\n") b.WriteString("-- Recompute snapshot depresiasi flock ini dengan kurva baru.\n") fmt.Fprintf(&b, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id = %d;\n", opts.ProjectFlockID) return b.String() } // formatValuesTuples renders the (day, mult) tuples 5 per line. The first tuple's // multiplier is cast ::numeric so PostgreSQL types the whole VALUES column as // numeric even if every value happens to render without a decimal point. func formatValuesTuples(curve []curveRow) string { tuples := make([]string, len(curve)) for i, row := range curve { mult := formatFloat(row.Mult) if i == 0 { mult += "::numeric" } tuples[i] = fmt.Sprintf("(%d, %s)", row.Day, mult) } var b strings.Builder const perLine = 5 for i := 0; i < len(tuples); i += perLine { end := i + perLine if end > len(tuples) { end = len(tuples) } b.WriteString(" ") b.WriteString(strings.Join(tuples[i:end], ", ")) if end < len(tuples) { b.WriteString(",") } if end < len(tuples) { b.WriteString("\n") } } return b.String() } func buildDownSQL(opts options) string { var b strings.Builder b.WriteString("DELETE FROM house_depreciation_standards\n") fmt.Fprintf(&b, "WHERE project_flock_id = %d AND effective_date = DATE '%s';\n\n", opts.ProjectFlockID, opts.EffectiveDate) fmt.Fprintf(&b, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id = %d;\n", opts.ProjectFlockID) return b.String() } func formatFloat(value float64) string { return strconv.FormatFloat(value, 'g', -1, 64) } func assertActiveLayingFlock(ctx context.Context, db *gorm.DB, projectFlockID uint) error { var count int64 err := db.WithContext(ctx). Table("project_flocks"). Where("id = ?", projectFlockID). Where("deleted_at IS NULL"). Where("category = ?", string(utils.ProjectFlockCategoryLaying)). Count(&count).Error if err != nil { return err } if count == 0 { return fmt.Errorf("project_flock_id %d must reference an active LAYING project_flock", projectFlockID) } return nil } func resolveHouseType(ctx context.Context, db *gorm.DB, projectFlockID uint, override string) (string, error) { if strings.TrimSpace(override) != "" { normalized, err := normalizeHouseType(override) if err != nil { return "", err } return normalized, nil } houseTypes := make([]string, 0) err := db.WithContext(ctx).Raw(` SELECT DISTINCT k.house_type::text AS house_type FROM project_flock_kandangs pfk JOIN kandangs k ON k.id = pfk.kandang_id WHERE pfk.project_flock_id = ? AND k.house_type IS NOT NULL ORDER BY house_type `, projectFlockID).Scan(&houseTypes).Error if err != nil { return "", err } switch len(houseTypes) { case 0: return "", fmt.Errorf("flock %d has no kandang house_type set; pass -house-type explicitly", projectFlockID) case 1: return houseTypes[0], nil default: return "", fmt.Errorf("flock %d spans multiple house_types %v; pass -house-type explicitly", projectFlockID, houseTypes) } } func normalizeHouseType(raw string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(raw)) switch normalized { case "open_house", "close_house": return normalized, nil default: return "", fmt.Errorf("house_type %q must be open_house or close_house", raw) } } func globalDaysForHouseType(ctx context.Context, db *gorm.DB, houseType string) (map[int]bool, error) { days := make([]int, 0) err := db.WithContext(ctx).Raw(` SELECT DISTINCT day FROM house_depreciation_standards WHERE project_flock_id IS NULL AND house_type = ?::house_type_enum `, houseType).Scan(&days).Error if err != nil { return nil, err } set := make(map[int]bool, len(days)) for _, day := range days { set[day] = true } return set, nil } func dayRange(curve []curveRow) (int, int) { minDay, maxDay := curve[0].Day, curve[0].Day for _, row := range curve { if row.Day < minDay { minDay = row.Day } if row.Day > maxDay { maxDay = row.Day } } return minDay, maxDay } 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 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 modeLabel(apply bool) string { if apply { return "APPLY" } return "DRY-RUN" }