add adjust migration

This commit is contained in:
giovanni
2026-04-19 21:13:48 +07:00
parent 04aad18a4c
commit 45bed3b765
15 changed files with 2153 additions and 0 deletions
@@ -0,0 +1,632 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"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"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
const dateLayout = "2006-01-02"
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
ProjectFlockID int
TotalCost int
CutoverDate int
Note int
}
type manualInputImportRow struct {
RowNumber int
ProjectFlockID uint
TotalCost float64
CutoverDate time.Time
Note *string
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() 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)
}
type farmResolver interface {
ResolveActiveLayingFarms(ctx context.Context, projectFlockIDs []uint) (map[uint]string, error)
}
type dbFarmResolver struct {
db *gorm.DB
}
type manualInputStore interface {
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
}
type txRunner interface {
InTx(ctx context.Context, fn func(store manualInputStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type expenseDepreciationStore struct {
repo repportRepo.ExpenseDepreciationRepository
}
type farmIdentityRow struct {
ID uint `gorm:"column:id"`
FarmName string `gorm:"column:farm_name"`
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
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)
}
sheetName, rows, parseIssues, err := parseManualInputFile(opts.FilePath, opts.Sheet, location)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbFarmResolver{db: db}
farmNameByID, err := resolver.ResolveActiveLayingFarms(ctx, collectProjectFlockIDs(rows))
if err != nil {
log.Fatalf("failed validating project_flock_id against project_flocks: %v", err)
}
issues := append([]validationIssue{}, parseIssues...)
issues = append(issues, buildMissingFarmIssues(rows, farmNameByID)...)
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("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Println()
if len(rows) > 0 {
printPlanRows(rows, farmNameByID)
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=0 failed=%d\n", len(rows), len(issues))
os.Exit(1)
}
if !opts.Apply {
fmt.Printf("Summary: planned=%d applied=0 failed=0\n", len(rows))
return
}
if len(rows) == 0 {
fmt.Println("Summary: planned=0 applied=0 failed=0")
return
}
if err := applyIfRequested(ctx, true, dbTxRunner{db: db}, rows); err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, row := range rows {
fmt.Printf(
"DONE row=%d project_flock_id=%d cutover_date=%s\n",
row.RowNumber,
row.ProjectFlockID,
row.CutoverDate.In(location).Format(dateLayout),
)
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=%d failed=0\n", len(rows), len(rows))
}
func parseManualInputFile(
filePath string,
requestedSheet string,
location *time.Location,
) (string, []manualInputImportRow, []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
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rows := make([]manualInputImportRow, 0, len(allRows)-1)
issues := make([]validationIssue, 0)
seenProjectFlockIDs := make(map[uint]int)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, location, seenProjectFlockIDs)
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",
})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if 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) (headerIndexes, []validationIssue) {
indexes := headerIndexes{
ProjectFlockID: -1,
TotalCost: -1,
CutoverDate: -1,
Note: -1,
}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "project_flock_id":
if indexes.ProjectFlockID >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header project_flock_id",
})
}
indexes.ProjectFlockID = idx
case "total_cost":
if indexes.TotalCost >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header total_cost",
})
}
indexes.TotalCost = idx
case "cutover_date":
if indexes.CutoverDate >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header cutover_date",
})
}
indexes.CutoverDate = idx
case "note":
if indexes.Note >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header note",
})
}
indexes.Note = idx
}
}
if indexes.ProjectFlockID < 0 {
issues = append(issues, validationIssue{
Field: "project_flock_id",
Message: "required header is missing",
})
}
if indexes.TotalCost < 0 {
issues = append(issues, validationIssue{
Field: "total_cost",
Message: "required header is missing",
})
}
if indexes.CutoverDate < 0 {
issues = append(issues, validationIssue{
Field: "cutover_date",
Message: "required header is missing",
})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
location *time.Location,
seenProjectFlockIDs map[uint]int,
) (*manualInputImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
projectFlockIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.ProjectFlockID))
projectFlockID, err := parsePositiveUint(projectFlockIDRaw)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "project_flock_id",
Message: err.Error(),
})
}
totalCostRaw := strings.TrimSpace(cellValue(rawRow, indexes.TotalCost))
totalCost, err := parseNonNegativeFloat(totalCostRaw)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "total_cost",
Message: err.Error(),
})
}
cutoverDateRaw := strings.TrimSpace(cellValue(rawRow, indexes.CutoverDate))
cutoverDate, err := parseDateOnlyInLocation(cutoverDateRaw, location)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "cutover_date",
Message: err.Error(),
})
}
var note *string
noteRaw := strings.TrimSpace(cellValue(rawRow, indexes.Note))
if noteRaw != "" {
if len([]rune(noteRaw)) > 1000 {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "note",
Message: "must have at most 1000 characters",
})
} else {
note = &noteRaw
}
}
if projectFlockID > 0 {
if previousRow, exists := seenProjectFlockIDs[projectFlockID]; exists {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "project_flock_id",
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", projectFlockID, previousRow),
})
} else {
seenProjectFlockIDs[projectFlockID] = rowNumber
}
}
if len(issues) > 0 {
return nil, issues
}
return &manualInputImportRow{
RowNumber: rowNumber,
ProjectFlockID: projectFlockID,
TotalCost: totalCost,
CutoverDate: cutoverDate,
Note: note,
}, nil
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func parseNonNegativeFloat(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 {
return 0, fmt.Errorf("must be greater than or equal to 0")
}
return value, nil
}
func parseDateOnlyInLocation(raw string, location *time.Location) (time.Time, error) {
if raw == "" {
return time.Time{}, fmt.Errorf("is required")
}
value, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD")
}
return value, nil
}
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 collectProjectFlockIDs(rows []manualInputImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.ProjectFlockID == 0 {
continue
}
if _, exists := seen[row.ProjectFlockID]; exists {
continue
}
seen[row.ProjectFlockID] = struct{}{}
ids = append(ids, row.ProjectFlockID)
}
sort.Slice(ids, func(i, j int) bool {
return ids[i] < ids[j]
})
return ids
}
func (r dbFarmResolver) ResolveActiveLayingFarms(
ctx context.Context,
projectFlockIDs []uint,
) (map[uint]string, error) {
result := make(map[uint]string)
if len(projectFlockIDs) == 0 {
return result, nil
}
rows := make([]farmIdentityRow, 0, len(projectFlockIDs))
if err := r.db.WithContext(ctx).
Table("project_flocks").
Select("id, flock_name AS farm_name").
Where("id IN ?", projectFlockIDs).
Where("deleted_at IS NULL").
Where("category = ?", utils.ProjectFlockCategoryLaying).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = row.FarmName
}
return result, nil
}
func buildMissingFarmIssues(rows []manualInputImportRow, farmNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
if _, exists := farmNameByID[row.ProjectFlockID]; exists {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "project_flock_id",
Message: fmt.Sprintf("value %d must reference an active LAYING project_flock", row.ProjectFlockID),
})
}
return issues
}
func printPlanRows(rows []manualInputImportRow, farmNameByID map[uint]string) {
for _, row := range rows {
farmName := farmNameByID[row.ProjectFlockID]
fmt.Printf(
"PLAN row=%d project_flock_id=%d farm_name=%q total_cost=%.3f cutover_date=%s note=%q\n",
row.RowNumber,
row.ProjectFlockID,
farmName,
row.TotalCost,
row.CutoverDate.Format(dateLayout),
derefString(row.Note),
)
}
}
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 applyIfRequested(ctx context.Context, apply bool, runner txRunner, rows []manualInputImportRow) error {
if !apply || len(rows) == 0 {
return nil
}
return applyImportRows(ctx, runner, rows)
}
func applyImportRows(ctx context.Context, runner txRunner, rows []manualInputImportRow) error {
return runner.InTx(ctx, func(store manualInputStore) error {
for _, row := range rows {
payload := entity.FarmDepreciationManualInput{
ProjectFlockId: row.ProjectFlockID,
TotalCost: row.TotalCost,
CutoverDate: row.CutoverDate,
Note: row.Note,
}
if err := store.UpsertManualInput(ctx, &payload); err != nil {
return fmt.Errorf("row %d project_flock_id=%d upsert failed: %w", row.RowNumber, row.ProjectFlockID, err)
}
if err := store.DeleteSnapshotsFromDate(ctx, row.CutoverDate, []uint{row.ProjectFlockID}); err != nil {
return fmt.Errorf("row %d project_flock_id=%d snapshot invalidation failed: %w", row.RowNumber, row.ProjectFlockID, err)
}
}
return nil
})
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repo := repportRepo.NewExpenseDepreciationRepository(tx)
store := expenseDepreciationStore{repo: repo}
return fn(store)
})
}
func (s expenseDepreciationStore) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error {
return s.repo.UpsertManualInput(ctx, row)
}
func (s expenseDepreciationStore) DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error {
return s.repo.DeleteSnapshotsFromDate(ctx, fromDate, farmIDs)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
@@ -0,0 +1,563 @@
package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func TestParseManualInputFile_ValidSingleRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "12345.678", "2026-06-01", "manual seed"},
},
)
location := mustJakartaLocation(t)
sheet, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "manual_inputs" {
t.Fatalf("expected selected sheet manual_inputs, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].ProjectFlockID != 101 {
t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID)
}
if rows[0].TotalCost != 12345.678 {
t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost)
}
if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" {
t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout))
}
if rows[0].Note == nil || *rows[0].Note != "manual seed" {
t.Fatalf("expected note manual seed, got %+v", rows[0].Note)
}
}
func TestParseManualInputFile_ValidMultiRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300.5", "2026-06-02", "second"},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if rows[1].Note == nil || *rows[1].Note != "second" {
t.Fatalf("expected second row note second, got %+v", rows[1].Note)
}
}
func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "totalcost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
}
if !hasIssue(issues, 0, "total_cost", "required header is missing") {
t.Fatalf("expected missing total_cost header issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"abc", "1200", "2026-06-01", ""},
{"0", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") {
t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues)
}
if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") {
t.Fatalf("expected project_flock_id >0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidTotalCost(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "abc", "2026-06-01", ""},
{"102", "-1", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "total_cost", "must be numeric") {
t.Fatalf("expected total_cost numeric issue, got %+v", issues)
}
if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") {
t.Fatalf("expected total_cost >=0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "06-01-2026", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") {
t.Fatalf("expected cutover_date format issue, got %+v", issues)
}
}
func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"101", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows))
}
if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") {
t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues)
}
}
func TestParseManualInputFile_NoteValidation(t *testing.T) {
longNote := strings.Repeat("a", 1001)
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300", "2026-06-02", longNote},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected only first row valid, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if !hasIssue(issues, 3, "note", "at most 1000 characters") {
t.Fatalf("expected note length issue, got %+v", issues)
}
}
func TestApplyImportRows_Success(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 2 {
t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 2 {
t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations))
}
if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 {
t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations)
}
}
func TestApplyImportRows_RollbackOnError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failUpsertOnProjectFlockID: 102,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected error due to upsert failure")
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 0 {
t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 0 {
t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations))
}
}
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyIfRequested(context.Background(), false, runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 0 {
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
}
}
func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
if sheetName == "" {
sheetName = defaultSheet
} else if sheetName != defaultSheet {
f.SetSheetName(defaultSheet, sheetName)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed resolving header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed resolving data cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting data cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "manual_inputs.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed closing workbook: %v", err)
}
return path
}
func mustJakartaLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading Asia/Jakarta location: %v", err)
}
return location
}
func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time {
t.Helper()
value, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, messageContains) {
return true
}
}
return false
}
type fakeInvalidation struct {
fromDate time.Time
farmIDs []uint
}
type fakeManualInputStore struct {
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
upserts []entity.FarmDepreciationManualInput
invalidations []fakeInvalidation
}
func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error {
if row == nil {
return nil
}
if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID {
return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId)
}
cloned := *row
s.upserts = append(s.upserts, cloned)
return nil
}
func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
if s.failDeleteOnProjectFlockID > 0 {
for _, farmID := range farmIDs {
if farmID == s.failDeleteOnProjectFlockID {
return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID)
}
}
}
copiedFarmIDs := append([]uint{}, farmIDs...)
s.invalidations = append(s.invalidations, fakeInvalidation{
fromDate: fromDate,
farmIDs: copiedFarmIDs,
})
return nil
}
type fakeTransactionRunner struct {
txCalls int
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
committedUpserts []entity.FarmDepreciationManualInput
committedInvalidations []fakeInvalidation
}
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
r.txCalls++
txStore := &fakeManualInputStore{
failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID,
failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID,
}
if err := fn(txStore); err != nil {
return err
}
r.committedUpserts = append(r.committedUpserts, txStore.upserts...)
r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...)
return nil
}
var _ txRunner = (*fakeTransactionRunner)(nil)
var _ manualInputStore = (*fakeManualInputStore)(nil)
func TestBuildMissingFarmIssues(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
issues := buildMissingFarmIssues(rows, map[uint]string{
101: "Farm A",
})
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %+v", issues)
}
if issues[0].Row != 3 || issues[0].Field != "project_flock_id" {
t.Fatalf("unexpected issue: %+v", issues[0])
}
}
func TestApplyImportRows_PropagatesDeleteError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failDeleteOnProjectFlockID: 101,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected delete failure")
}
if !strings.Contains(err.Error(), "snapshot invalidation failed") {
t.Fatalf("expected snapshot invalidation error message, got %v", err)
}
}
func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) {
workbook := excelize.NewFile()
defer func() {
_ = workbook.Close()
}()
_, err := resolveSheetName(workbook, "unknown")
if err == nil {
t.Fatal("expected error when sheet is missing")
}
}
func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
runner := &errorTxRunner{err: errors.New("tx failed")}
err := applyIfRequested(context.Background(), true, runner, rows)
if err == nil {
t.Fatal("expected transaction error")
}
if err.Error() != "tx failed" {
t.Fatalf("unexpected error: %v", err)
}
}
type errorTxRunner struct {
err error
}
func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error {
return r.err
}