mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
add migration for seed to standar depresiasi and update data cutover sesuai excel ebitda
This commit is contained in:
@@ -1,18 +1,22 @@
|
||||
// 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.
|
||||
// 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_id IS NULL) untuk hari yang sama,
|
||||
// diwarisi dari baris global (project_flock_ids 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.
|
||||
// Jalankan lokal (tidak ada API yang di-hit di production):
|
||||
//
|
||||
// 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
|
||||
// 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 (
|
||||
@@ -35,27 +39,27 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
dateLayout = "2006-01-02"
|
||||
timestampLayout = "20060102150405"
|
||||
defaultMigrions = "internal/database/migrations"
|
||||
headerDay = "day"
|
||||
headerMultiplier = "multiplication_percentage"
|
||||
dateLayout = "2006-01-02"
|
||||
timestampLayout = "20060102150405"
|
||||
defaultMigrations = "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
|
||||
ProjectFlockIDs string // comma-separated flock IDs (wajib, min 1)
|
||||
FilePath string
|
||||
Sheet string
|
||||
EffectiveDate string
|
||||
HouseType string
|
||||
OutDir string
|
||||
Apply bool
|
||||
}
|
||||
|
||||
type curveRow struct {
|
||||
RowNumber int
|
||||
Day int
|
||||
Mult float64
|
||||
Day int
|
||||
Mult float64
|
||||
ColRef string // Excel column letter (e.g. "B"), untuk error reporting
|
||||
}
|
||||
|
||||
type validationIssue struct {
|
||||
@@ -73,22 +77,27 @@ func (i validationIssue) String() string {
|
||||
|
||||
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.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 opts.ProjectFlockID == 0 {
|
||||
log.Fatal("--project-flock-id is required and must be greater than 0")
|
||||
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")
|
||||
}
|
||||
@@ -112,30 +121,27 @@ func main() {
|
||||
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)
|
||||
for _, id := range flockIDs {
|
||||
if err := assertActiveLayingFlock(ctx, db, id); err != nil {
|
||||
log.Fatalf("flock %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
houseType, err := resolveHouseType(ctx, db, opts.ProjectFlockID, opts.HouseType)
|
||||
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...)
|
||||
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("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 {
|
||||
@@ -153,12 +159,13 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
upSQL := buildUpSQL(opts, houseType, curve)
|
||||
downSQL := buildDownSQL(opts)
|
||||
upSQL := buildUpSQL(opts, houseTypes, curve, flockIDs)
|
||||
downSQL := buildDownSQL(opts, flockIDs)
|
||||
|
||||
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)
|
||||
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)
|
||||
@@ -181,6 +188,62 @@ func main() {
|
||||
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 == "" {
|
||||
@@ -194,6 +257,15 @@ func resolveEffectiveDate(raw string, location *time.Location) (time.Time, error
|
||||
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 {
|
||||
@@ -211,35 +283,84 @@ func parseCurveFile(filePath, requestedSheet string) (string, []curveRow, []vali
|
||||
return "", nil, nil, err
|
||||
}
|
||||
if len(allRows) == 0 {
|
||||
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
|
||||
return sheetName, nil, []validationIssue{{Field: "sheet", Message: "sheet is empty"}}, nil
|
||||
}
|
||||
|
||||
dayIdx, multIdx, headerIssues := parseHeaderIndexes(allRows[0])
|
||||
if len(headerIssues) > 0 {
|
||||
return sheetName, nil, headerIssues, 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
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]curveRow, 0, len(allRows)-1)
|
||||
issues := make([]validationIssue, 0)
|
||||
seenDays := make(map[int]int)
|
||||
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
|
||||
}
|
||||
|
||||
for idx := 1; idx < len(allRows); idx++ {
|
||||
rowNumber := idx + 1
|
||||
rawRow := allRows[idx]
|
||||
if isRowEmpty(rawRow) {
|
||||
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
|
||||
}
|
||||
|
||||
parsed, rowIssues := parseCurveRow(rawRow, rowNumber, dayIdx, multIdx, seenDays)
|
||||
if len(rowIssues) > 0 {
|
||||
issues = append(issues, rowIssues...)
|
||||
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, *parsed)
|
||||
rows = append(rows, curveRow{Day: day, Mult: mult, ColRef: colName})
|
||||
}
|
||||
|
||||
if len(rows) == 0 && len(issues) == 0 {
|
||||
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
|
||||
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 })
|
||||
@@ -262,61 +383,6 @@ func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, e
|
||||
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")
|
||||
@@ -343,57 +409,79 @@ func parseMultiplication(raw string) (float64, error) {
|
||||
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")
|
||||
if value < 0 || value > 1 {
|
||||
return 0, fmt.Errorf("must be between 0 and 1 (inclusive)")
|
||||
}
|
||||
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 {
|
||||
// --- 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 project_flock_id=%d (house_type=%s, effective_date=%s).\n", opts.ProjectFlockID, houseType, opts.EffectiveDate)
|
||||
|
||||
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("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)
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 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 {
|
||||
@@ -414,27 +502,27 @@ func formatValuesTuples(curve []curveRow) string {
|
||||
b.WriteString(" ")
|
||||
b.WriteString(strings.Join(tuples[i:end], ", "))
|
||||
if end < len(tuples) {
|
||||
b.WriteString(",")
|
||||
}
|
||||
if end < len(tuples) {
|
||||
b.WriteString("\n")
|
||||
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()
|
||||
// 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).
|
||||
@@ -452,13 +540,16 @@ func assertActiveLayingFlock(ctx context.Context, db *gorm.DB, projectFlockID ui
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveHouseType(ctx context.Context, db *gorm.DB, projectFlockID uint, override string) (string, error) {
|
||||
// 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) != "" {
|
||||
normalized, err := normalizeHouseType(override)
|
||||
ht, err := normalizeHouseType(override)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return normalized, nil
|
||||
return []string{ht}, nil
|
||||
}
|
||||
|
||||
houseTypes := make([]string, 0)
|
||||
@@ -466,21 +557,19 @@ func resolveHouseType(ctx context.Context, db *gorm.DB, projectFlockID uint, ove
|
||||
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
|
||||
WHERE pfk.project_flock_id IN ? AND k.house_type IS NOT NULL
|
||||
ORDER BY house_type
|
||||
`, projectFlockID).Scan(&houseTypes).Error
|
||||
`, flockIDs).Scan(&houseTypes).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, 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)
|
||||
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) {
|
||||
@@ -493,22 +582,8 @@ func normalizeHouseType(raw string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// --- misc helpers ------------------------------------------------------------
|
||||
|
||||
func dayRange(curve []curveRow) (int, int) {
|
||||
minDay, maxDay := curve[0].Day, curve[0].Day
|
||||
|
||||
@@ -1,32 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
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)
|
||||
// --- helper: build horizontal-format temp xlsx --------------------------------
|
||||
|
||||
// makeHorizXlsx creates a temp .xlsx with two horizontal rows:
|
||||
//
|
||||
// Row 1: "day" | dayValues...
|
||||
// Row 2: "multiplication_percentage" | multValues...
|
||||
func makeHorizXlsx(t *testing.T, dayValues, multValues []any) string {
|
||||
t.Helper()
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
|
||||
f.SetCellValue("Sheet1", "A1", "day")
|
||||
f.SetCellValue("Sheet1", "A2", "multiplication_percentage")
|
||||
|
||||
for i, v := range dayValues {
|
||||
colName, _ := excelize.ColumnNumberToName(i + 2)
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
f.SetCellInt("Sheet1", colName+"1", val)
|
||||
case string:
|
||||
f.SetCellValue("Sheet1", colName+"1", val)
|
||||
}
|
||||
if dayIdx != 1 || multIdx != 0 {
|
||||
t.Fatalf("dayIdx=%d multIdx=%d", dayIdx, multIdx)
|
||||
}
|
||||
for i, v := range multValues {
|
||||
colName, _ := excelize.ColumnNumberToName(i + 2)
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
f.SetCellFloat("Sheet1", colName+"2", val, 15, 64)
|
||||
case string:
|
||||
f.SetCellValue("Sheet1", colName+"2", val)
|
||||
}
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "test_curve_*.xlsx")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
tmp.Close()
|
||||
t.Cleanup(func() { os.Remove(tmp.Name()) })
|
||||
if err := f.SaveAs(tmp.Name()); err != nil {
|
||||
t.Fatalf("SaveAs: %v", err)
|
||||
}
|
||||
return tmp.Name()
|
||||
}
|
||||
|
||||
// --- parseCurveFile tests -----------------------------------------------------
|
||||
|
||||
func TestParseCurveFile_HappyPath(t *testing.T) {
|
||||
path := makeHorizXlsx(t,
|
||||
[]any{1, 2, 3},
|
||||
[]any{0.997742664, 0.997737557, 0.997732426},
|
||||
)
|
||||
sheetName, rows, issues, err := parseCurveFile(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("unexpected issues: %v", issues)
|
||||
}
|
||||
if sheetName != "Sheet1" {
|
||||
t.Fatalf("wrong sheet: %s", sheetName)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want 3 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Day != 1 || rows[1].Day != 2 || rows[2].Day != 3 {
|
||||
t.Fatalf("wrong days: %v", rows)
|
||||
}
|
||||
if rows[0].ColRef != "B" {
|
||||
t.Fatalf("wrong ColRef for day 1: %s", rows[0].ColRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCurveFile_RowOrderFlexible(t *testing.T) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
f.SetCellValue("Sheet1", "A1", "multiplication_percentage")
|
||||
f.SetCellFloat("Sheet1", "B1", 0.997, 15, 64)
|
||||
f.SetCellValue("Sheet1", "A2", "day")
|
||||
f.SetCellInt("Sheet1", "B2", 5)
|
||||
|
||||
tmp, _ := os.CreateTemp("", "*.xlsx")
|
||||
tmp.Close()
|
||||
t.Cleanup(func() { os.Remove(tmp.Name()) })
|
||||
f.SaveAs(tmp.Name())
|
||||
|
||||
_, rows, issues, err := parseCurveFile(tmp.Name(), "")
|
||||
if err != nil || len(issues) != 0 {
|
||||
t.Fatalf("err=%v issues=%v", err, issues)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Day != 5 {
|
||||
t.Fatalf("unexpected rows: %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCurveFile_MissingDayRow(t *testing.T) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
f.SetCellValue("Sheet1", "A1", "multiplication_percentage")
|
||||
f.SetCellFloat("Sheet1", "B1", 0.997, 15, 64)
|
||||
|
||||
tmp, _ := os.CreateTemp("", "*.xlsx")
|
||||
tmp.Close()
|
||||
t.Cleanup(func() { os.Remove(tmp.Name()) })
|
||||
f.SaveAs(tmp.Name())
|
||||
|
||||
_, _, issues, err := parseCurveFile(tmp.Name(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(issues) != 1 || issues[0].Field != headerDay {
|
||||
t.Fatalf("expected missing-day-row issue, got %v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCurveFile_DuplicateDay(t *testing.T) {
|
||||
path := makeHorizXlsx(t,
|
||||
[]any{1, 2, 1},
|
||||
[]any{0.99, 0.98, 0.97},
|
||||
)
|
||||
_, _, issues, err := parseCurveFile(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("expected 1 duplicate-day issue, got %v", issues)
|
||||
}
|
||||
if !strings.Contains(issues[0].Message, "duplicate day 1") {
|
||||
t.Fatalf("wrong issue message: %s", issues[0].Message)
|
||||
}
|
||||
if !strings.Contains(issues[0].Message, "col=D") {
|
||||
t.Fatalf("issue should mention col=D: %s", issues[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCurveFile_InvalidMultiplication(t *testing.T) {
|
||||
path := makeHorizXlsx(t,
|
||||
[]any{1, 2},
|
||||
[]any{"bad", 1.5},
|
||||
)
|
||||
_, _, issues, err := parseCurveFile(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(issues) != 2 {
|
||||
t.Fatalf("expected 2 issues, got %v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCurveFile_SkipsEmptyColumns(t *testing.T) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
f.SetCellValue("Sheet1", "A1", "day")
|
||||
f.SetCellInt("Sheet1", "B1", 1)
|
||||
f.SetCellInt("Sheet1", "D1", 3)
|
||||
f.SetCellValue("Sheet1", "A2", "multiplication_percentage")
|
||||
f.SetCellFloat("Sheet1", "B2", 0.997, 15, 64)
|
||||
f.SetCellFloat("Sheet1", "D2", 0.995, 15, 64)
|
||||
|
||||
tmp, _ := os.CreateTemp("", "*.xlsx")
|
||||
tmp.Close()
|
||||
t.Cleanup(func() { os.Remove(tmp.Name()) })
|
||||
f.SaveAs(tmp.Name())
|
||||
|
||||
_, rows, issues, err := parseCurveFile(tmp.Name(), "")
|
||||
if err != nil || len(issues) != 0 {
|
||||
t.Fatalf("err=%v issues=%v", err, issues)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 rows (col C skipped), got %d: %v", len(rows), rows)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pure-function tests ------------------------------------------------------
|
||||
|
||||
func TestParseFlockIDs(t *testing.T) {
|
||||
t.Run("single", func(t *testing.T) {
|
||||
ids, err := parseFlockIDs("52")
|
||||
if err != nil || len(ids) != 1 || ids[0] != 52 {
|
||||
t.Fatalf("got ids=%v err=%v", ids, err)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
t.Run("multiple_sorted", func(t *testing.T) {
|
||||
ids, err := parseFlockIDs("54,52,53")
|
||||
if err != nil || len(ids) != 3 {
|
||||
t.Fatalf("got ids=%v err=%v", ids, err)
|
||||
}
|
||||
// should be sorted
|
||||
if ids[0] != 52 || ids[1] != 53 || ids[2] != 54 {
|
||||
t.Fatalf("expected sorted [52,53,54], got %v", ids)
|
||||
}
|
||||
})
|
||||
t.Run("duplicate", func(t *testing.T) {
|
||||
if _, err := parseFlockIDs("52,52"); err == nil {
|
||||
t.Fatal("expected error for duplicate")
|
||||
}
|
||||
})
|
||||
t.Run("zero", func(t *testing.T) {
|
||||
if _, err := parseFlockIDs("0"); err == nil {
|
||||
t.Fatal("expected error for zero")
|
||||
}
|
||||
})
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
if _, err := parseFlockIDs(""); err == nil {
|
||||
t.Fatal("expected error for empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatArrayLiteral(t *testing.T) {
|
||||
got := formatArrayLiteral([]uint{52, 53, 54})
|
||||
want := "ARRAY[52,53,54]::bigint[]"
|
||||
if got != want {
|
||||
t.Fatalf("want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFlockIDsForFilename(t *testing.T) {
|
||||
if got := formatFlockIDsForFilename([]uint{52, 53, 54}); got != "52_53_54" {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
// More than 4 IDs → truncate
|
||||
many := []uint{1, 2, 3, 4, 5}
|
||||
got := formatFlockIDsForFilename(many)
|
||||
if !strings.Contains(got, "1_2_3_4") || !strings.Contains(got, "1_more") {
|
||||
t.Fatalf("unexpected: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
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 {
|
||||
@@ -39,7 +255,10 @@ func TestParsePositiveInt(t *testing.T) {
|
||||
}
|
||||
|
||||
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}
|
||||
cases := map[string]bool{
|
||||
"0.997742664": true, "1": true, "9.11e-12": true, "0": true,
|
||||
"-0.1": false, "1.0001": false, "": false, "abc": false,
|
||||
}
|
||||
for raw, ok := range cases {
|
||||
_, err := parseMultiplication(raw)
|
||||
if ok && err != nil {
|
||||
@@ -51,28 +270,12 @@ func TestParseMultiplication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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}})
|
||||
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)
|
||||
}
|
||||
@@ -81,25 +284,49 @@ func TestFormatValuesTuplesFirstNumericCast(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpSQL(t *testing.T) {
|
||||
opts := options{ProjectFlockID: 52, EffectiveDate: "2026-05-31"}
|
||||
func TestBuildUpSQL_SingleHouseType(t *testing.T) {
|
||||
opts := options{EffectiveDate: "2026-05-31"}
|
||||
curve := []curveRow{{Day: 1, Mult: 0.997742664}, {Day: 2, Mult: 0.5}}
|
||||
sql := buildUpSQL(opts, "close_house", curve)
|
||||
flockIDs := []uint{52, 53}
|
||||
sql := buildUpSQL(opts, []string{"close_house"}, curve, flockIDs)
|
||||
|
||||
mustContain(t, sql, "INSERT INTO house_depreciation_standards")
|
||||
mustContain(t, sql, "52, g.house_type, g.day, DATE '2026-05-31'")
|
||||
mustContain(t, sql, "project_flock_ids")
|
||||
mustContain(t, sql, "ARRAY[52,53]::bigint[]")
|
||||
mustContain(t, sql, "DATE '2026-05-31'")
|
||||
mustContain(t, sql, "v.mult, (1 - v.mult) * 100, g.standard_week")
|
||||
mustContain(t, sql, "project_flock_ids IS NULL")
|
||||
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;")
|
||||
mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (52, 53)")
|
||||
}
|
||||
|
||||
func TestBuildUpSQL_MultipleHouseTypes(t *testing.T) {
|
||||
opts := options{EffectiveDate: "2026-05-31"}
|
||||
curve := []curveRow{{Day: 1, Mult: 0.5}}
|
||||
flockIDs := []uint{52, 53}
|
||||
sql := buildUpSQL(opts, []string{"close_house", "open_house"}, curve, flockIDs)
|
||||
|
||||
// Both house_types get their own INSERT block
|
||||
mustContain(t, sql, "house_type = 'close_house'::house_type_enum")
|
||||
mustContain(t, sql, "house_type = 'open_house'::house_type_enum")
|
||||
// VALUES tuple appears only once (reused by both blocks)
|
||||
mustContain(t, sql, "(1, 0.5::numeric)")
|
||||
// Snapshot invalidation appears once at the end
|
||||
mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (52, 53)")
|
||||
}
|
||||
|
||||
func TestBuildDownSQL(t *testing.T) {
|
||||
sql := buildDownSQL(options{ProjectFlockID: 52, EffectiveDate: "2026-05-31"})
|
||||
opts := options{EffectiveDate: "2026-05-31"}
|
||||
flockIDs := []uint{52, 53}
|
||||
sql := buildDownSQL(opts, flockIDs)
|
||||
|
||||
// Delete by exact array match
|
||||
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;")
|
||||
mustContain(t, sql, "project_flock_ids = ARRAY[52,53]::bigint[]")
|
||||
mustContain(t, sql, "effective_date = DATE '2026-05-31'")
|
||||
mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (52, 53)")
|
||||
}
|
||||
|
||||
func TestResolveEffectiveDate(t *testing.T) {
|
||||
@@ -130,6 +357,14 @@ func TestNormalizeHouseType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInClause(t *testing.T) {
|
||||
if got := inClause([]uint{52, 53, 54}); got != "52, 53, 54" {
|
||||
t.Fatalf("wrong inClause: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------------
|
||||
|
||||
func mustContain(t *testing.T, haystack, needle string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(haystack, needle) {
|
||||
|
||||
Reference in New Issue
Block a user