// Command seed-house-depreciation-standards membaca kurva depresiasi per-day // dari file Excel, lalu meng-generate file migration {up,down}.sql yang // menyisipkan baris house_depreciation_standards dengan project_flock_ids // berisi semua flock yang memakai kurva tersebut. // // Kurva disimpan SEKALI di DB sebagai satu baris dengan // project_flock_ids = ARRAY[52,53,54]::bigint[]. Lookup di engine pakai // ? = ANY(project_flock_ids), sehingga tidak ada duplikasi baris. // // Hanya multiplication_percentage yang di-override; house_type & standard_week // diwarisi dari baris global (project_flock_ids IS NULL) untuk hari yang sama, // dan depreciation_percent diturunkan = (1 - multiplication_percentage) * 100. // // Jalankan lokal (tidak ada API yang di-hit di production): // // go run ./cmd/seed-house-depreciation-standards \ // -project-flock-ids=52,53,54 -file=curve.xlsx # dry-run // go run ./cmd/seed-house-depreciation-standards \ // -project-flock-ids=52,53,54 -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" defaultMigrations = "internal/database/migrations" headerDay = "day" headerMultiplier = "multiplication_percentage" ) type options struct { ProjectFlockIDs string // comma-separated flock IDs (wajib, min 1) FilePath string Sheet string EffectiveDate string HouseType string OutDir string Apply bool } type curveRow struct { Day int Mult float64 ColRef string // Excel column letter (e.g. "B"), untuk error reporting } 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.StringVar(&opts.ProjectFlockIDs, "project-flock-ids", "", "Comma-separated LAYING project flock IDs yang memakai kurva ini (required, e.g. 52,53,54)") flag.StringVar(&opts.FilePath, "file", "", "Path ke .xlsx — format horizontal: baris 'day' dan 'multiplication_percentage' (required)") flag.StringVar(&opts.Sheet, "sheet", "", "Nama sheet (opsional; default: sheet pertama)") flag.StringVar(&opts.EffectiveDate, "effective-date", "", "effective_date untuk baris yang di-insert (YYYY-MM-DD; default: hari ini)") flag.StringVar(&opts.HouseType, "house-type", "", "Override house_type (open_house|close_house). Default: di-derive dari kandang flock") flag.StringVar(&opts.OutDir, "out-dir", defaultMigrations, "Direktori output file migration") flag.BoolVar(&opts.Apply, "apply", false, "Tulis file migration. Jika false: dry-run (cetak SQL ke stdout)") flag.Parse() opts.FilePath = strings.TrimSpace(opts.FilePath) opts.Sheet = strings.TrimSpace(opts.Sheet) opts.OutDir = strings.TrimSpace(opts.OutDir) if strings.TrimSpace(opts.ProjectFlockIDs) == "" { log.Fatal("--project-flock-ids is required") } flockIDs, err := parseFlockIDs(opts.ProjectFlockIDs) if err != nil { log.Fatalf("--project-flock-ids: %v", err) } 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) for _, id := range flockIDs { if err := assertActiveLayingFlock(ctx, db, id); err != nil { log.Fatalf("flock %d: %v", id, err) } } houseTypes, err := resolveHouseTypesForFlocks(ctx, db, flockIDs, opts.HouseType) if err != nil { log.Fatalf("house_type resolution failed: %v", err) } // Days yang tidak ada di global standard cukup di-skip oleh JOIN LATERAL (inner join) — // tidak perlu validasi coverage; tidak ada global row = tidak ada INSERT, bukan error. issues := append([]validationIssue{}, parseIssues...) 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 IDs: %s\n", formatFlockIDs(flockIDs)) fmt.Printf("House types: %s\n", strings.Join(houseTypes, ", ")) 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, houseTypes, curve, flockIDs) downSQL := buildDownSQL(opts, flockIDs) prefix := time.Now().In(location).Format(timestampLayout) suffix := formatFlockIDsForFilename(flockIDs) upName := fmt.Sprintf("%s_seed_house_depreciation_flocks_%s.up.sql", prefix, suffix) downName := fmt.Sprintf("%s_seed_house_depreciation_flocks_%s.down.sql", prefix, suffix) 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`.") } // --- validation helpers ------------------------------------------------------- func parseFlockIDs(raw string) ([]uint, error) { parts := strings.Split(raw, ",") ids := make([]uint, 0, len(parts)) seen := make(map[uint]bool) for _, p := range parts { p = strings.TrimSpace(p) if p == "" { continue } n, err := strconv.ParseUint(p, 10, 64) if err != nil || n == 0 { return nil, fmt.Errorf("invalid flock ID %q: must be a positive integer", p) } id := uint(n) if seen[id] { return nil, fmt.Errorf("duplicate flock ID %d", id) } seen[id] = true ids = append(ids, id) } if len(ids) == 0 { return nil, fmt.Errorf("at least one project flock ID required") } // Sort IDs so generated SQL and filename are deterministic. sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) return ids, nil } func formatFlockIDs(ids []uint) string { parts := make([]string, len(ids)) for i, id := range ids { parts[i] = strconv.FormatUint(uint64(id), 10) } return strings.Join(parts, ", ") } // formatFlockIDsForFilename returns "52_53_54" for use in migration filenames. // Truncates to first 4 IDs if many, to keep filename reasonable. func formatFlockIDsForFilename(ids []uint) string { display := ids suffix := "" if len(ids) > 4 { display = ids[:4] suffix = fmt.Sprintf("_and_%d_more", len(ids)-4) } parts := make([]string, len(display)) for i, id := range display { parts[i] = strconv.FormatUint(uint64(id), 10) } return strings.Join(parts, "_") + suffix } // --- effective date ----------------------------------------------------------- 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 } // --- excel parsing ----------------------------------------------------------- // parseCurveFile membaca format horizontal dari Excel: // // Baris 1 (label "day"): day | 1 | 2 | 3 | ... // Baris 2 (label "multiplication_percentage"): mp | 0.997.. | 0.997.. | ... // // Kedua baris bisa ada di urutan berapa pun, dideteksi lewat label di kolom A. // Kolom A adalah label; data mulai dari kolom B seterusnya. 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: "sheet", Message: "sheet is empty"}}, nil } var dayRow, multRow []string for _, row := range allRows { if len(row) == 0 { continue } switch normalizeHeader(row[0]) { case headerDay: dayRow = row case headerMultiplier: multRow = row } } issues := make([]validationIssue, 0) if dayRow == nil { issues = append(issues, validationIssue{Field: headerDay, Message: `baris dengan label "day" di kolom A tidak ditemukan`}) } if multRow == nil { issues = append(issues, validationIssue{Field: headerMultiplier, Message: `baris dengan label "multiplication_percentage" di kolom A tidak ditemukan`}) } if len(issues) > 0 { return sheetName, nil, issues, nil } maxCols := len(dayRow) if len(multRow) > maxCols { maxCols = len(multRow) } rows := make([]curveRow, 0, maxCols-1) seenDays := make(map[int]string) for colIdx := 1; colIdx < maxCols; colIdx++ { dayRaw := strings.TrimSpace(cellValue(dayRow, colIdx)) multRaw := strings.TrimSpace(cellValue(multRow, colIdx)) if dayRaw == "" && multRaw == "" { continue } colName, _ := excelize.ColumnNumberToName(colIdx + 1) var colIssues []validationIssue day, dayErr := parsePositiveInt(dayRaw) if dayErr != nil { colIssues = append(colIssues, validationIssue{ Field: headerDay, Message: fmt.Sprintf("col=%s: %s", colName, dayErr.Error()), }) } mult, multErr := parseMultiplication(multRaw) if multErr != nil { colIssues = append(colIssues, validationIssue{ Field: headerMultiplier, Message: fmt.Sprintf("col=%s: %s", colName, multErr.Error()), }) } if day > 0 { if prevCol, exists := seenDays[day]; exists { colIssues = append(colIssues, validationIssue{ Field: headerDay, Message: fmt.Sprintf("col=%s: duplicate day %d (already in col %s)", colName, day, prevCol), }) } else { seenDays[day] = colName } } if len(colIssues) > 0 { issues = append(issues, colIssues...) continue } rows = append(rows, curveRow{Day: day, Mult: mult, ColRef: colName}) } if len(rows) == 0 && len(issues) == 0 { issues = append(issues, validationIssue{Field: "data", Message: "tidak ada kolom data setelah kolom A (label)"}) } 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 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 between 0 and 1 (inclusive)") } return value, nil } // --- SQL generation ---------------------------------------------------------- // buildUpSQL generates INSERT blocks for each houseType in houseTypes. // If flocks span multiple house_types, one INSERT block is generated per type, // all sharing the same project_flock_ids array. func buildUpSQL(opts options, houseTypes []string, curve []curveRow, flockIDs []uint) string { var b strings.Builder fmt.Fprintf(&b, "-- Kurva depresiasi khusus flock %s (house_types=%s, effective_date=%s).\n", formatFlockIDs(flockIDs), strings.Join(houseTypes, ","), 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("-- Lookup engine: ? = ANY(project_flock_ids) — satu baris dipakai semua flock.\n\n") valTuples := formatValuesTuples(curve) arrayLit := formatArrayLiteral(flockIDs) for _, houseType := range houseTypes { fmt.Fprintf(&b, "-- house_type: %s\n", houseType) b.WriteString("INSERT INTO house_depreciation_standards\n") b.WriteString(" (project_flock_ids, house_type, day, effective_date,\n") b.WriteString(" multiplication_percentage, depreciation_percent, standard_week, name)\n") b.WriteString("SELECT\n") fmt.Fprintf(&b, " %s, g.house_type, g.day, DATE '%s',\n", arrayLit, opts.EffectiveDate) b.WriteString(" v.mult, (1 - v.mult) * 100, g.standard_week,\n") fmt.Fprintf(&b, " 'Custom flocks %s (eff %s)'\n", formatFlockIDs(flockIDs), opts.EffectiveDate) b.WriteString("FROM (VALUES\n") b.WriteString(valTuples) 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_ids 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 untuk semua flock yang dipetakan.\n") fmt.Fprintf(&b, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (%s);\n", inClause(flockIDs)) return b.String() } func buildDownSQL(opts options, flockIDs []uint) string { var b strings.Builder b.WriteString("-- Hapus baris kurva custom dari house_depreciation_standards.\n") b.WriteString("-- Exact match pada array (IDs di-sort, sama persis dengan yang di-insert).\n") fmt.Fprintf(&b, "DELETE FROM house_depreciation_standards\nWHERE project_flock_ids = %s\n AND effective_date = DATE '%s';\n\n", formatArrayLiteral(flockIDs), opts.EffectiveDate) b.WriteString("-- Recompute snapshot depresiasi.\n") fmt.Fprintf(&b, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (%s);\n", inClause(flockIDs)) return b.String() } // formatArrayLiteral renders []uint{52,53,54} as ARRAY[52,53,54]::bigint[]. func formatArrayLiteral(ids []uint) string { parts := make([]string, len(ids)) for i, id := range ids { parts[i] = strconv.FormatUint(uint64(id), 10) } return fmt.Sprintf("ARRAY[%s]::bigint[]", strings.Join(parts, ",")) } // formatValuesTuples renders (day, mult) tuples 5 per line. // The first multiplier is cast ::numeric so PostgreSQL infers the column type correctly. 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(",\n") } } return b.String() } // inClause formats []uint{52,53,54} as "52, 53, 54" for SQL IN (...). func inClause(ids []uint) string { parts := make([]string, len(ids)) for i, id := range ids { parts[i] = strconv.FormatUint(uint64(id), 10) } return strings.Join(parts, ", ") } func formatFloat(value float64) string { return strconv.FormatFloat(value, 'g', -1, 64) } // --- DB helpers -------------------------------------------------------------- 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 } // resolveHouseTypesForFlocks mengembalikan SEMUA distinct house_type dari kandang // semua flock yang diberikan. Kalau -house-type di-pass → hanya type itu. // Tidak error kalau multiple (generate INSERT block per type secara otomatis). func resolveHouseTypesForFlocks(ctx context.Context, db *gorm.DB, flockIDs []uint, override string) ([]string, error) { if strings.TrimSpace(override) != "" { ht, err := normalizeHouseType(override) if err != nil { return nil, err } return []string{ht}, 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 IN ? AND k.house_type IS NOT NULL ORDER BY house_type `, flockIDs).Scan(&houseTypes).Error if err != nil { return nil, err } if len(houseTypes) == 0 { return nil, fmt.Errorf("no kandang house_type found for any of the specified flocks; pass -house-type explicitly") } // Bisa 1 atau lebih — generate INSERT block per type. return houseTypes, nil } 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) } } // --- misc helpers ------------------------------------------------------------ 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" }