add migration for seed to standar depresiasi and update data cutover sesuai excel ebitda

This commit is contained in:
giovanni
2026-06-03 22:08:26 +07:00
parent 968305fad0
commit a51e5302c3
22 changed files with 1580 additions and 265 deletions
@@ -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) {