From 44b82a8e3878399e629ada3c72de6ee36a70d5aa Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 1 Jun 2026 21:07:30 +0700 Subject: [PATCH] init add function command for create seed depretitaion standard --- cmd/seed-house-depreciation-standards/main.go | 563 ++++++++++++++++++ .../main_test.go | 138 +++++ .../repository/common.hppv2.repository.go | 8 +- .../common/service/common.hppv2.service.go | 4 +- .../service/common.hppv2.service_test.go | 2 +- ...d_to_house_depreciation_standards.down.sql | 15 + ..._id_to_house_depreciation_standards.up.sql | 18 + .../expense_depreciation.repository.go | 8 +- 8 files changed, 747 insertions(+), 9 deletions(-) create mode 100644 cmd/seed-house-depreciation-standards/main.go create mode 100644 cmd/seed-house-depreciation-standards/main_test.go create mode 100644 internal/database/migrations/20260531213223_add_project_flock_id_to_house_depreciation_standards.down.sql create mode 100644 internal/database/migrations/20260531213223_add_project_flock_id_to_house_depreciation_standards.up.sql diff --git a/cmd/seed-house-depreciation-standards/main.go b/cmd/seed-house-depreciation-standards/main.go new file mode 100644 index 00000000..4098211c --- /dev/null +++ b/cmd/seed-house-depreciation-standards/main.go @@ -0,0 +1,563 @@ +// 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" +} diff --git a/cmd/seed-house-depreciation-standards/main_test.go b/cmd/seed-house-depreciation-standards/main_test.go new file mode 100644 index 00000000..679fbad0 --- /dev/null +++ b/cmd/seed-house-depreciation-standards/main_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestParseHeaderIndexes(t *testing.T) { + t.Run("finds both columns regardless of order/case", func(t *testing.T) { + dayIdx, multIdx, issues := parseHeaderIndexes([]string{"Multiplication_Percentage", "Day"}) + if len(issues) != 0 { + t.Fatalf("unexpected issues: %v", issues) + } + if dayIdx != 1 || multIdx != 0 { + t.Fatalf("dayIdx=%d multIdx=%d", dayIdx, multIdx) + } + }) + + t.Run("missing headers reported", func(t *testing.T) { + _, _, issues := parseHeaderIndexes([]string{"foo", "bar"}) + if len(issues) != 2 { + t.Fatalf("want 2 issues, got %v", issues) + } + }) +} + +func TestParsePositiveInt(t *testing.T) { + cases := map[string]bool{"1": true, "532": true, "12.0": true, "0": false, "-3": false, "1.5": false, "": false, "x": false} + for raw, ok := range cases { + _, err := parsePositiveInt(raw) + if ok && err != nil { + t.Errorf("%q: unexpected error %v", raw, err) + } + if !ok && err == nil { + t.Errorf("%q: expected error", raw) + } + } +} + +func TestParseMultiplication(t *testing.T) { + cases := map[string]bool{"0.997742664": true, "1": true, "9.11e-12": true, "0": false, "-0.1": false, "1.0001": false, "": false, "abc": false} + for raw, ok := range cases { + _, err := parseMultiplication(raw) + if ok && err != nil { + t.Errorf("%q: unexpected error %v", raw, err) + } + if !ok && err == nil { + t.Errorf("%q: expected error", raw) + } + } +} + +func TestParseCurveRowDuplicateDay(t *testing.T) { + seen := map[int]int{} + if _, issues := parseCurveRow([]string{"1", "0.99"}, 2, 0, 1, seen); len(issues) != 0 { + t.Fatalf("row 1 should be valid, got %v", issues) + } + _, issues := parseCurveRow([]string{"1", "0.98"}, 3, 0, 1, seen) + if len(issues) != 1 || !strings.Contains(issues[0].Message, "duplicate day 1") { + t.Fatalf("expected duplicate-day issue, got %v", issues) + } +} + +func TestBuildDayCoverageIssues(t *testing.T) { + curve := []curveRow{{RowNumber: 2, Day: 1, Mult: 0.99}, {RowNumber: 3, Day: 999, Mult: 0.99}} + global := map[int]bool{1: true} + issues := buildDayCoverageIssues(curve, global, "close_house") + if len(issues) != 1 || issues[0].Row != 3 { + t.Fatalf("expected 1 issue for day 999, got %v", issues) + } +} + +func TestFormatValuesTuplesFirstNumericCast(t *testing.T) { + out := formatValuesTuples([]curveRow{{Day: 1, Mult: 0.997742664}, {Day: 2, Mult: 1}}) + if !strings.Contains(out, "(1, 0.997742664::numeric)") { + t.Fatalf("first tuple must cast ::numeric: %s", out) + } + if strings.Contains(out, "(2, 1::numeric)") { + t.Fatalf("only the first tuple should be cast: %s", out) + } +} + +func TestBuildUpSQL(t *testing.T) { + opts := options{ProjectFlockID: 52, EffectiveDate: "2026-05-31"} + curve := []curveRow{{Day: 1, Mult: 0.997742664}, {Day: 2, Mult: 0.5}} + sql := buildUpSQL(opts, "close_house", curve) + + mustContain(t, sql, "INSERT INTO house_depreciation_standards") + mustContain(t, sql, "52, g.house_type, g.day, DATE '2026-05-31'") + mustContain(t, sql, "v.mult, (1 - v.mult) * 100, g.standard_week") + mustContain(t, sql, "house_type = 'close_house'::house_type_enum") + mustContain(t, sql, "(1, 0.997742664::numeric)") + mustContain(t, sql, "JOIN LATERAL") + mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id = 52;") +} + +func TestBuildDownSQL(t *testing.T) { + sql := buildDownSQL(options{ProjectFlockID: 52, EffectiveDate: "2026-05-31"}) + mustContain(t, sql, "DELETE FROM house_depreciation_standards") + mustContain(t, sql, "WHERE project_flock_id = 52 AND effective_date = DATE '2026-05-31';") + mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id = 52;") +} + +func TestResolveEffectiveDate(t *testing.T) { + loc := time.UTC + if _, err := resolveEffectiveDate("2026-05-31", loc); err != nil { + t.Fatalf("valid date errored: %v", err) + } + if _, err := resolveEffectiveDate("31-05-2026", loc); err == nil { + t.Fatalf("expected error for wrong format") + } + got, err := resolveEffectiveDate("", loc) + if err != nil { + t.Fatalf("default date errored: %v", err) + } + if got.Hour() != 0 || got.Minute() != 0 { + t.Fatalf("default date should be midnight, got %v", got) + } +} + +func TestNormalizeHouseType(t *testing.T) { + for _, ok := range []string{"open_house", "CLOSE_HOUSE", " close_house "} { + if _, err := normalizeHouseType(ok); err != nil { + t.Errorf("%q should be valid: %v", ok, err) + } + } + if _, err := normalizeHouseType("barn"); err == nil { + t.Errorf("barn should be invalid") + } +} + +func mustContain(t *testing.T, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Fatalf("expected to find %q in:\n%s", needle, haystack) + } +} diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index 787599e5..79c0690e 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -112,7 +112,7 @@ type HppV2CostRepository interface { GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) GetChickinPopulationByPFKForFarm(ctx context.Context, projectFlockID uint) (map[uint]float64, error) - GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, map[string]*time.Time, error) + GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, projectFlockID uint) (map[string]map[int]float64, map[string]*time.Time, error) ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error) ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error) ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) @@ -466,6 +466,7 @@ func (r *HppV2RepositoryImpl) GetMultiplicationPercentages( ctx context.Context, houseTypes []string, maxDay int, + projectFlockID uint, ) (map[string]map[int]float64, map[string]*time.Time, error) { result := make(map[string]map[int]float64) effectiveDates := make(map[string]*time.Time) @@ -486,8 +487,9 @@ func (r *HppV2RepositoryImpl) GetMultiplicationPercentages( house_type::text AS house_type, day, multiplication_percentage, effective_date FROM house_depreciation_standards WHERE house_type::text IN ? AND day <= ? - ORDER BY house_type, day, effective_date DESC NULLS LAST - `, houseTypes, maxDay).Scan(&rows).Error + AND (project_flock_id = ? OR project_flock_id IS NULL) + ORDER BY house_type, day, (project_flock_id IS NOT NULL) DESC, effective_date DESC NULLS LAST + `, houseTypes, maxDay, projectFlockID).Scan(&rows).Error if err != nil { return nil, nil, err } diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index 5b32e446..b171109b 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -1390,7 +1390,7 @@ func (s *hppV2Service) buildNormalTransferDepreciationPart( } houseType := NormalizeDepreciationHouseType(contextRow.HouseType) - multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, scheduleDay) + multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, scheduleDay, contextRow.ProjectFlockID) if err != nil { return nil, err } @@ -1499,7 +1499,7 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart( } houseType := NormalizeDepreciationHouseType(contextRow.HouseType) - multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, reportScheduleDay) + multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, reportScheduleDay, contextRow.ProjectFlockID) if err != nil { return nil, err } diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go index 346af535..5cbf0523 100644 --- a/internal/common/service/common.hppv2.service_test.go +++ b/internal/common/service/common.hppv2.service_test.go @@ -103,7 +103,7 @@ func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes [] // GetMultiplicationPercentages — alias yang sama dengan GetDepreciationPercents untuk match // interface HppV2CostRepository (interface dipakai method name baru ini). -func (s *hppV2RepoStub) GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, map[string]*time.Time, error) { +func (s *hppV2RepoStub) GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, _ uint) (map[string]map[int]float64, map[string]*time.Time, error) { vals, err := s.GetDepreciationPercents(ctx, houseTypes, maxDay) return vals, make(map[string]*time.Time), err } diff --git a/internal/database/migrations/20260531213223_add_project_flock_id_to_house_depreciation_standards.down.sql b/internal/database/migrations/20260531213223_add_project_flock_id_to_house_depreciation_standards.down.sql new file mode 100644 index 00000000..206c8479 --- /dev/null +++ b/internal/database/migrations/20260531213223_add_project_flock_id_to_house_depreciation_standards.down.sql @@ -0,0 +1,15 @@ +-- Hapus baris per-flock dulu supaya unique lama (house_type, day, effective_date) +-- bisa dipulihkan tanpa konflik duplikat antar-flock. +DELETE FROM house_depreciation_standards WHERE project_flock_id IS NOT NULL; + +DROP INDEX IF EXISTS idx_hds_project_flock_id; + +ALTER TABLE house_depreciation_standards + DROP CONSTRAINT house_depreciation_standards_htype_day_eff_pf_unique; + +ALTER TABLE house_depreciation_standards + DROP COLUMN project_flock_id; + +ALTER TABLE house_depreciation_standards + ADD CONSTRAINT house_depreciation_standards_house_type_day_eff_unique + UNIQUE (house_type, day, effective_date); diff --git a/internal/database/migrations/20260531213223_add_project_flock_id_to_house_depreciation_standards.up.sql b/internal/database/migrations/20260531213223_add_project_flock_id_to_house_depreciation_standards.up.sql new file mode 100644 index 00000000..b941353d --- /dev/null +++ b/internal/database/migrations/20260531213223_add_project_flock_id_to_house_depreciation_standards.up.sql @@ -0,0 +1,18 @@ +-- Tambah dimensi per-project-flock ke standar depresiasi. +-- Baris dengan project_flock_id NON-NULL = kurva khusus flock tsb. +-- Baris dengan project_flock_id NULL = kurva global default (fallback). +ALTER TABLE house_depreciation_standards + ADD COLUMN project_flock_id BIGINT NULL REFERENCES project_flocks(id); + +-- Unique lama (house_type, day, effective_date) tidak bisa membedakan baris per-flock. +-- Ganti agar menyertakan project_flock_id (NULL dianggap distinct di PostgreSQL, +-- sehingga baris global lama tidak konflik dengan baris per-flock baru). +ALTER TABLE house_depreciation_standards + DROP CONSTRAINT house_depreciation_standards_house_type_day_eff_unique; + +ALTER TABLE house_depreciation_standards + ADD CONSTRAINT house_depreciation_standards_htype_day_eff_pf_unique + UNIQUE (house_type, day, effective_date, project_flock_id); + +CREATE INDEX idx_hds_project_flock_id + ON house_depreciation_standards (project_flock_id); diff --git a/internal/modules/repports/repositories/expense_depreciation.repository.go b/internal/modules/repports/repositories/expense_depreciation.repository.go index 39223b83..cd061d7b 100644 --- a/internal/modules/repports/repositories/expense_depreciation.repository.go +++ b/internal/modules/repports/repositories/expense_depreciation.repository.go @@ -51,7 +51,7 @@ type ExpenseDepreciationRepository interface { DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error DeleteSnapshotsByFarmIDs(ctx context.Context, farmIDs []uint) error GetLatestTransferInputsByFarms(ctx context.Context, period time.Time, farmIDs []uint) ([]FarmDepreciationLatestTransferRow, error) - GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, map[string]*time.Time, error) + GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, projectFlockID uint) (map[string]map[int]float64, map[string]*time.Time, error) GetLatestManualInputsByFarms(ctx context.Context, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationManualInputRow, error) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error DB() *gorm.DB @@ -245,6 +245,7 @@ func (r *expenseDepreciationRepository) GetMultiplicationPercentages( ctx context.Context, houseTypes []string, maxDay int, + projectFlockID uint, ) (map[string]map[int]float64, map[string]*time.Time, error) { result := make(map[string]map[int]float64) effectiveDates := make(map[string]*time.Time) @@ -258,8 +259,9 @@ func (r *expenseDepreciationRepository) GetMultiplicationPercentages( house_type::text AS house_type, day, multiplication_percentage, effective_date FROM house_depreciation_standards WHERE house_type::text IN ? AND day <= ? - ORDER BY house_type, day, effective_date DESC NULLS LAST - `, houseTypes, maxDay).Scan(&rows).Error; err != nil { + AND (project_flock_id = ? OR project_flock_id IS NULL) + ORDER BY house_type, day, (project_flock_id IS NOT NULL) DESC, effective_date DESC NULLS LAST + `, houseTypes, maxDay, projectFlockID).Scan(&rows).Error; err != nil { return nil, nil, err }