init add function command for create seed depretitaion standard

This commit is contained in:
giovanni
2026-06-01 21:07:30 +07:00
parent 09b1f19d19
commit 44b82a8e38
8 changed files with 747 additions and 9 deletions
@@ -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"
}
@@ -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)
}
}
@@ -112,7 +112,7 @@ type HppV2CostRepository interface {
GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error)
GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error)
GetChickinPopulationByPFKForFarm(ctx context.Context, projectFlockID uint) (map[uint]float64, 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) 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) 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) ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error)
@@ -466,6 +466,7 @@ func (r *HppV2RepositoryImpl) GetMultiplicationPercentages(
ctx context.Context, ctx context.Context,
houseTypes []string, houseTypes []string,
maxDay int, maxDay int,
projectFlockID uint,
) (map[string]map[int]float64, map[string]*time.Time, error) { ) (map[string]map[int]float64, map[string]*time.Time, error) {
result := make(map[string]map[int]float64) result := make(map[string]map[int]float64)
effectiveDates := make(map[string]*time.Time) 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 house_type::text AS house_type, day, multiplication_percentage, effective_date
FROM house_depreciation_standards FROM house_depreciation_standards
WHERE house_type::text IN ? AND day <= ? WHERE house_type::text IN ? AND day <= ?
ORDER BY house_type, day, effective_date DESC NULLS LAST AND (project_flock_id = ? OR project_flock_id IS NULL)
`, houseTypes, maxDay).Scan(&rows).Error 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -1390,7 +1390,7 @@ func (s *hppV2Service) buildNormalTransferDepreciationPart(
} }
houseType := NormalizeDepreciationHouseType(contextRow.HouseType) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -1499,7 +1499,7 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart(
} }
houseType := NormalizeDepreciationHouseType(contextRow.HouseType) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -103,7 +103,7 @@ func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []
// GetMultiplicationPercentages — alias yang sama dengan GetDepreciationPercents untuk match // GetMultiplicationPercentages — alias yang sama dengan GetDepreciationPercents untuk match
// interface HppV2CostRepository (interface dipakai method name baru ini). // 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) vals, err := s.GetDepreciationPercents(ctx, houseTypes, maxDay)
return vals, make(map[string]*time.Time), err return vals, make(map[string]*time.Time), err
} }
@@ -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);
@@ -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);
@@ -51,7 +51,7 @@ type ExpenseDepreciationRepository interface {
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
DeleteSnapshotsByFarmIDs(ctx context.Context, farmIDs []uint) error DeleteSnapshotsByFarmIDs(ctx context.Context, farmIDs []uint) error
GetLatestTransferInputsByFarms(ctx context.Context, period time.Time, farmIDs []uint) ([]FarmDepreciationLatestTransferRow, 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) GetLatestManualInputsByFarms(ctx context.Context, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationManualInputRow, error)
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
DB() *gorm.DB DB() *gorm.DB
@@ -245,6 +245,7 @@ func (r *expenseDepreciationRepository) GetMultiplicationPercentages(
ctx context.Context, ctx context.Context,
houseTypes []string, houseTypes []string,
maxDay int, maxDay int,
projectFlockID uint,
) (map[string]map[int]float64, map[string]*time.Time, error) { ) (map[string]map[int]float64, map[string]*time.Time, error) {
result := make(map[string]map[int]float64) result := make(map[string]map[int]float64)
effectiveDates := make(map[string]*time.Time) 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 house_type::text AS house_type, day, multiplication_percentage, effective_date
FROM house_depreciation_standards FROM house_depreciation_standards
WHERE house_type::text IN ? AND day <= ? WHERE house_type::text IN ? AND day <= ?
ORDER BY house_type, day, effective_date DESC NULLS LAST AND (project_flock_id = ? OR project_flock_id IS NULL)
`, houseTypes, maxDay).Scan(&rows).Error; err != nil { 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 return nil, nil, err
} }