mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
add adjust migration
This commit is contained in:
@@ -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 = ¬eRaw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type importOptions struct {
|
||||||
|
FilePath string
|
||||||
|
Sheet string
|
||||||
|
Apply bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type headerIndexes struct {
|
||||||
|
KandangID int
|
||||||
|
KandangName int
|
||||||
|
HouseType int
|
||||||
|
}
|
||||||
|
|
||||||
|
type kandangHouseTypeImportRow struct {
|
||||||
|
RowNumber int
|
||||||
|
KandangID uint
|
||||||
|
KandangName string
|
||||||
|
HouseType 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 kandangResolver interface {
|
||||||
|
ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dbKandangResolver struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type txRunner interface {
|
||||||
|
InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type dbTxRunner struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type kandangHouseTypeStore interface {
|
||||||
|
UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error)
|
||||||
|
NormalizeNullHouseType(ctx context.Context) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dbKandangHouseTypeStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type kandangIdentityRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type applyRowResult struct {
|
||||||
|
RowNumber int
|
||||||
|
KandangID uint
|
||||||
|
HouseType string
|
||||||
|
Changed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
sheetName, rows, parseIssues, err := parseKandangHouseTypeFile(opts.FilePath, opts.Sheet)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed reading excel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
resolver := dbKandangResolver{db: db}
|
||||||
|
|
||||||
|
kandangNameByID, err := resolver.ResolveActiveKandangs(ctx, collectKandangIDs(rows))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed validating kandang_id against kandangs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
issues := append([]validationIssue{}, parseIssues...)
|
||||||
|
issues = append(issues, buildMissingKandangIssues(rows, kandangNameByID)...)
|
||||||
|
issues = append(issues, buildNameMismatchIssues(rows, kandangNameByID)...)
|
||||||
|
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, kandangNameByID)
|
||||||
|
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 normalized_null_to_open_house=0 failed=%d\n", len(rows), len(issues))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.Apply {
|
||||||
|
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=0\n", len(rows))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowResults, normalizedCount, err := applyImportRows(ctx, dbTxRunner{db: db}, rows)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("apply failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range rowResults {
|
||||||
|
fmt.Printf(
|
||||||
|
"DONE row=%d kandang_id=%d house_type=%s status=%s\n",
|
||||||
|
result.RowNumber,
|
||||||
|
result.KandangID,
|
||||||
|
result.HouseType,
|
||||||
|
applyStatus(result.Changed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
appliedCount := countChangedRows(rowResults)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(
|
||||||
|
"Summary: planned=%d applied=%d normalized_null_to_open_house=%d failed=0\n",
|
||||||
|
len(rows),
|
||||||
|
appliedCount,
|
||||||
|
normalizedCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKandangHouseTypeFile(
|
||||||
|
filePath string,
|
||||||
|
requestedSheet string,
|
||||||
|
) (string, []kandangHouseTypeImportRow, []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([]kandangHouseTypeImportRow, 0, len(allRows)-1)
|
||||||
|
issues := make([]validationIssue, 0)
|
||||||
|
seenKandangIDs := 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, seenKandangIDs)
|
||||||
|
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{KandangID: -1, KandangName: -1, HouseType: -1}
|
||||||
|
issues := make([]validationIssue, 0)
|
||||||
|
|
||||||
|
for idx, raw := range headerRow {
|
||||||
|
header := normalizeHeader(raw)
|
||||||
|
if header == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch header {
|
||||||
|
case "kandang_id":
|
||||||
|
if indexes.KandangID >= 0 {
|
||||||
|
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_id"})
|
||||||
|
}
|
||||||
|
indexes.KandangID = idx
|
||||||
|
case "kandang_name":
|
||||||
|
if indexes.KandangName >= 0 {
|
||||||
|
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_name"})
|
||||||
|
}
|
||||||
|
indexes.KandangName = idx
|
||||||
|
case "house_type", "type_house":
|
||||||
|
if indexes.HouseType >= 0 {
|
||||||
|
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header house_type"})
|
||||||
|
}
|
||||||
|
indexes.HouseType = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if indexes.KandangID < 0 {
|
||||||
|
issues = append(issues, validationIssue{Field: "kandang_id", Message: "required header is missing"})
|
||||||
|
}
|
||||||
|
if indexes.KandangName < 0 {
|
||||||
|
issues = append(issues, validationIssue{Field: "kandang_name", Message: "required header is missing"})
|
||||||
|
}
|
||||||
|
if indexes.HouseType < 0 {
|
||||||
|
issues = append(issues, validationIssue{Field: "house_type", Message: "required header is missing"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexes, issues
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDataRow(
|
||||||
|
rawRow []string,
|
||||||
|
rowNumber int,
|
||||||
|
indexes headerIndexes,
|
||||||
|
seenKandangIDs map[uint]int,
|
||||||
|
) (*kandangHouseTypeImportRow, []validationIssue) {
|
||||||
|
issues := make([]validationIssue, 0)
|
||||||
|
|
||||||
|
kandangIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangID))
|
||||||
|
kandangID, err := parsePositiveUint(kandangIDRaw)
|
||||||
|
if err != nil {
|
||||||
|
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_id", Message: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
kandangNameRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangName))
|
||||||
|
if kandangNameRaw == "" {
|
||||||
|
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_name", Message: "is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
houseTypeRaw := strings.TrimSpace(cellValue(rawRow, indexes.HouseType))
|
||||||
|
houseType, err := normalizeHouseType(houseTypeRaw)
|
||||||
|
if err != nil {
|
||||||
|
issues = append(issues, validationIssue{Row: rowNumber, Field: "house_type", Message: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
if kandangID > 0 {
|
||||||
|
if previousRow, exists := seenKandangIDs[kandangID]; exists {
|
||||||
|
issues = append(issues, validationIssue{
|
||||||
|
Row: rowNumber,
|
||||||
|
Field: "kandang_id",
|
||||||
|
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", kandangID, previousRow),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
seenKandangIDs[kandangID] = rowNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) > 0 {
|
||||||
|
return nil, issues
|
||||||
|
}
|
||||||
|
|
||||||
|
return &kandangHouseTypeImportRow{
|
||||||
|
RowNumber: rowNumber,
|
||||||
|
KandangID: kandangID,
|
||||||
|
KandangName: kandangNameRaw,
|
||||||
|
HouseType: houseType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHouseType(raw string) (string, error) {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if normalized == "" {
|
||||||
|
return string(utils.HouseTypeOpenHouse), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch normalized {
|
||||||
|
case string(utils.HouseTypeOpenHouse), string(utils.HouseTypeCloseHouse):
|
||||||
|
return normalized, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("must be one of: open_house, close_house (or empty for default open_house)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 collectKandangIDs(rows []kandangHouseTypeImportRow) []uint {
|
||||||
|
ids := make([]uint, 0, len(rows))
|
||||||
|
seen := make(map[uint]struct{}, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.KandangID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[row.KandangID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[row.KandangID] = struct{}{}
|
||||||
|
ids = append(ids, row.KandangID)
|
||||||
|
}
|
||||||
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r dbKandangResolver) ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) {
|
||||||
|
result := make(map[uint]string)
|
||||||
|
if len(kandangIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]kandangIdentityRow, 0, len(kandangIDs))
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Table("kandangs").
|
||||||
|
Select("id, name").
|
||||||
|
Where("id IN ?", kandangIDs).
|
||||||
|
Where("deleted_at IS NULL").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
result[row.ID] = row.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMissingKandangIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
|
||||||
|
issues := make([]validationIssue, 0)
|
||||||
|
for _, row := range rows {
|
||||||
|
if _, exists := kandangNameByID[row.KandangID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
issues = append(issues, validationIssue{
|
||||||
|
Row: row.RowNumber,
|
||||||
|
Field: "kandang_id",
|
||||||
|
Message: fmt.Sprintf("value %d must reference an active kandang", row.KandangID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNameMismatchIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
|
||||||
|
issues := make([]validationIssue, 0)
|
||||||
|
for _, row := range rows {
|
||||||
|
dbName, exists := kandangNameByID[row.KandangID]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(row.KandangName), strings.TrimSpace(dbName)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
issues = append(issues, validationIssue{
|
||||||
|
Row: row.RowNumber,
|
||||||
|
Field: "kandang_name",
|
||||||
|
Message: fmt.Sprintf("value %q does not match kandang_id %d name %q", row.KandangName, row.KandangID, dbName),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPlanRows(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) {
|
||||||
|
for _, row := range rows {
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN row=%d kandang_id=%d kandang_name_file=%q kandang_name_db=%q house_type=%q\n",
|
||||||
|
row.RowNumber,
|
||||||
|
row.KandangID,
|
||||||
|
row.KandangName,
|
||||||
|
kandangNameByID[row.KandangID],
|
||||||
|
row.HouseType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 applyImportRows(
|
||||||
|
ctx context.Context,
|
||||||
|
runner txRunner,
|
||||||
|
rows []kandangHouseTypeImportRow,
|
||||||
|
) ([]applyRowResult, int64, error) {
|
||||||
|
results := make([]applyRowResult, 0, len(rows))
|
||||||
|
normalizedNullCount := int64(0)
|
||||||
|
|
||||||
|
err := runner.InTx(ctx, func(store kandangHouseTypeStore) error {
|
||||||
|
for _, row := range rows {
|
||||||
|
changed, err := store.UpdateKandangHouseType(ctx, row.KandangID, row.HouseType)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("row %d kandang_id=%d update failed: %w", row.RowNumber, row.KandangID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, applyRowResult{
|
||||||
|
RowNumber: row.RowNumber,
|
||||||
|
KandangID: row.KandangID,
|
||||||
|
HouseType: row.HouseType,
|
||||||
|
Changed: changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := store.NormalizeNullHouseType(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("normalize null house_type to open_house failed: %w", err)
|
||||||
|
}
|
||||||
|
normalizedNullCount = normalized
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, normalizedNullCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r dbTxRunner) InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error {
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
return fn(dbKandangHouseTypeStore{db: tx})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s dbKandangHouseTypeStore) UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) {
|
||||||
|
result := s.db.WithContext(ctx).Exec(`
|
||||||
|
UPDATE kandangs
|
||||||
|
SET house_type = ?::house_type_enum,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND house_type IS DISTINCT FROM ?::house_type_enum
|
||||||
|
`, houseType, kandangID, houseType)
|
||||||
|
if result.Error != nil {
|
||||||
|
return false, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s dbKandangHouseTypeStore) NormalizeNullHouseType(ctx context.Context) (int64, error) {
|
||||||
|
result := s.db.WithContext(ctx).Exec(`
|
||||||
|
UPDATE kandangs
|
||||||
|
SET house_type = 'open_house'::house_type_enum,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND house_type IS NULL
|
||||||
|
`)
|
||||||
|
if result.Error != nil {
|
||||||
|
return 0, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyStatus(changed bool) string {
|
||||||
|
if changed {
|
||||||
|
return "UPDATED"
|
||||||
|
}
|
||||||
|
return "UNCHANGED"
|
||||||
|
}
|
||||||
|
|
||||||
|
func countChangedRows(results []applyRowResult) int {
|
||||||
|
count := 0
|
||||||
|
for _, item := range results {
|
||||||
|
if item.Changed {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseKandangHouseTypeFile_ValidSingleRowAndDefaultHouseType(t *testing.T) {
|
||||||
|
filePath := createWorkbook(
|
||||||
|
t,
|
||||||
|
"kandang_house_type",
|
||||||
|
[]string{"kandang_id", "kandang_name", "house_type"},
|
||||||
|
[][]string{{"101", "Kandang A1", ""}},
|
||||||
|
)
|
||||||
|
|
||||||
|
sheet, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if sheet != "kandang_house_type" {
|
||||||
|
t.Fatalf("expected sheet kandang_house_type, 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].KandangID != 101 {
|
||||||
|
t.Fatalf("expected kandang_id 101, got %d", rows[0].KandangID)
|
||||||
|
}
|
||||||
|
if rows[0].KandangName != "Kandang A1" {
|
||||||
|
t.Fatalf("expected kandang_name Kandang A1, got %q", rows[0].KandangName)
|
||||||
|
}
|
||||||
|
if rows[0].HouseType != "open_house" {
|
||||||
|
t.Fatalf("expected default house_type open_house, got %q", rows[0].HouseType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKandangHouseTypeFile_TypeHouseHeaderAlias(t *testing.T) {
|
||||||
|
filePath := createWorkbook(
|
||||||
|
t,
|
||||||
|
"kandang_house_type",
|
||||||
|
[]string{"kandang_id", "kandang_name", "type_house"},
|
||||||
|
[][]string{{"101", "Kandang A1", "close_house"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "kandang_house_type")
|
||||||
|
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) != 1 || rows[0].HouseType != "close_house" {
|
||||||
|
t.Fatalf("expected parsed close_house row, got %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKandangHouseTypeFile_InvalidHouseType(t *testing.T) {
|
||||||
|
filePath := createWorkbook(
|
||||||
|
t,
|
||||||
|
"kandang_house_type",
|
||||||
|
[]string{"kandang_id", "kandang_name", "house_type"},
|
||||||
|
[][]string{{"101", "Kandang A1", "semi_house"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
|
||||||
|
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, "house_type", "must be one of") {
|
||||||
|
t.Fatalf("expected invalid house_type issue, got %+v", issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKandangHouseTypeFile_DuplicateKandangID(t *testing.T) {
|
||||||
|
filePath := createWorkbook(
|
||||||
|
t,
|
||||||
|
"kandang_house_type",
|
||||||
|
[]string{"kandang_id", "kandang_name", "house_type"},
|
||||||
|
[][]string{
|
||||||
|
{"101", "Kandang A1", "open_house"},
|
||||||
|
{"101", "Kandang A2", "close_house"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("expected first row valid and second invalid, got %d", len(rows))
|
||||||
|
}
|
||||||
|
if !hasIssue(issues, 3, "kandang_id", "duplicate value 101") {
|
||||||
|
t.Fatalf("expected duplicate kandang_id issue, got %+v", issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNameMismatchIssues(t *testing.T) {
|
||||||
|
rows := []kandangHouseTypeImportRow{{
|
||||||
|
RowNumber: 2,
|
||||||
|
KandangID: 10,
|
||||||
|
KandangName: "Kandang Salah",
|
||||||
|
HouseType: "open_house",
|
||||||
|
}}
|
||||||
|
|
||||||
|
issues := buildNameMismatchIssues(rows, map[uint]string{10: "Kandang Benar"})
|
||||||
|
if !hasIssue(issues, 2, "kandang_name", "does not match") {
|
||||||
|
t.Fatalf("expected name mismatch issue, got %+v", issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyImportRows_Success(t *testing.T) {
|
||||||
|
store := &fakeStore{
|
||||||
|
changedByID: map[uint]bool{101: true, 102: false},
|
||||||
|
normalizeResult: 3,
|
||||||
|
}
|
||||||
|
runner := &fakeTransactionRunner{store: store}
|
||||||
|
|
||||||
|
rows := []kandangHouseTypeImportRow{
|
||||||
|
{RowNumber: 2, KandangID: 101, HouseType: "open_house"},
|
||||||
|
{RowNumber: 3, KandangID: 102, HouseType: "close_house"},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, normalized, 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 tx call, got %d", runner.txCalls)
|
||||||
|
}
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("expected 2 row results, got %d", len(results))
|
||||||
|
}
|
||||||
|
if normalized != 3 {
|
||||||
|
t.Fatalf("expected normalized count 3, got %d", normalized)
|
||||||
|
}
|
||||||
|
if !results[0].Changed || results[1].Changed {
|
||||||
|
t.Fatalf("unexpected changed flags: %+v", results)
|
||||||
|
}
|
||||||
|
if len(store.updateCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 update calls, got %d", len(store.updateCalls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyImportRows_FailOnUpdate(t *testing.T) {
|
||||||
|
store := &fakeStore{
|
||||||
|
updateErrByID: map[uint]error{101: errors.New("boom")},
|
||||||
|
}
|
||||||
|
runner := &fakeTransactionRunner{store: store}
|
||||||
|
|
||||||
|
rows := []kandangHouseTypeImportRow{{RowNumber: 2, KandangID: 101, HouseType: "open_house"}}
|
||||||
|
|
||||||
|
_, _, err := applyImportRows(context.Background(), runner, rows)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "update failed") {
|
||||||
|
t.Fatalf("expected update failed error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountChangedRows(t *testing.T) {
|
||||||
|
count := countChangedRows([]applyRowResult{{Changed: true}, {Changed: false}, {Changed: true}})
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("expected 2 changed rows, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTransactionRunner struct {
|
||||||
|
store *fakeStore
|
||||||
|
txCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTransactionRunner) InTx(_ context.Context, fn func(store kandangHouseTypeStore) error) error {
|
||||||
|
f.txCalls++
|
||||||
|
return fn(f.store)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateCall struct {
|
||||||
|
kandangID uint
|
||||||
|
houseType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
updateCalls []updateCall
|
||||||
|
changedByID map[uint]bool
|
||||||
|
updateErrByID map[uint]error
|
||||||
|
normalizeResult int64
|
||||||
|
normalizeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateKandangHouseType(_ context.Context, kandangID uint, houseType string) (bool, error) {
|
||||||
|
f.updateCalls = append(f.updateCalls, updateCall{kandangID: kandangID, houseType: houseType})
|
||||||
|
if err, exists := f.updateErrByID[kandangID]; exists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if changed, exists := f.changedByID[kandangID]; exists {
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) NormalizeNullHouseType(_ context.Context) (int64, error) {
|
||||||
|
if f.normalizeErr != nil {
|
||||||
|
return 0, f.normalizeErr
|
||||||
|
}
|
||||||
|
return f.normalizeResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f := excelize.NewFile()
|
||||||
|
if sheetName == "" {
|
||||||
|
sheetName = "Sheet1"
|
||||||
|
}
|
||||||
|
defaultSheet := f.GetSheetName(0)
|
||||||
|
if defaultSheet != sheetName {
|
||||||
|
idx, err := f.NewSheet(sheetName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed creating sheet: %v", err)
|
||||||
|
}
|
||||||
|
f.SetActiveSheet(idx)
|
||||||
|
_ = f.DeleteSheet(defaultSheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, header := range headers {
|
||||||
|
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed computing 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 computing row cell: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.SetCellValue(sheetName, cell, value); err != nil {
|
||||||
|
t.Fatalf("failed setting row cell: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "kandang_house_type.xlsx")
|
||||||
|
if err := f.SaveAs(path); err != nil {
|
||||||
|
t.Fatalf("failed saving workbook: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasIssue(issues []validationIssue, row int, field string, contains string) bool {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.Row != row {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if issue.Field != field {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(issue.Message, contains) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Farm Depreciation Manual Inputs Import
|
||||||
|
|
||||||
|
Command ini dipakai untuk bulk import data ke tabel `farm_depreciation_manual_inputs` dari file Excel (`.xlsx`).
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/import-farm-depreciation-manual-inputs --file <path.xlsx> [--sheet <name>] [--apply]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flags
|
||||||
|
|
||||||
|
- `--file` (required): path file `.xlsx`.
|
||||||
|
- `--sheet` (optional): nama sheet. Jika tidak diisi, command pakai sheet pertama.
|
||||||
|
- `--apply` (optional): default `false` (dry-run). Jika `true`, command menulis ke database.
|
||||||
|
|
||||||
|
## Mode
|
||||||
|
|
||||||
|
- Dry-run (default):
|
||||||
|
- parsing dan validasi semua baris.
|
||||||
|
- validasi `project_flock_id` terhadap farm aktif kategori `LAYING`.
|
||||||
|
- menampilkan `PLAN` + daftar error.
|
||||||
|
- tidak menulis data.
|
||||||
|
|
||||||
|
- Apply (`--apply`):
|
||||||
|
- semua validasi tetap dijalankan dulu.
|
||||||
|
- jika ada 1 error, proses dihentikan.
|
||||||
|
- jika valid, upsert dijalankan dalam 1 transaksi (fail-fast).
|
||||||
|
- setelah upsert, snapshot di `farm_depreciation_snapshots` dihapus mulai `cutover_date` untuk `project_flock_id` terkait.
|
||||||
|
|
||||||
|
## Format Excel
|
||||||
|
|
||||||
|
Template tersedia di:
|
||||||
|
|
||||||
|
- `docs/templates/farm_depreciation_manual_inputs.xlsx`
|
||||||
|
|
||||||
|
Header wajib ada di baris 1 (case-insensitive, trim-spaces):
|
||||||
|
|
||||||
|
- `project_flock_id` (required, integer > 0)
|
||||||
|
- `total_cost` (required, numeric >= 0)
|
||||||
|
- `cutover_date` (required, format `YYYY-MM-DD`)
|
||||||
|
- `note` (optional, max 1000 karakter)
|
||||||
|
|
||||||
|
Catatan:
|
||||||
|
|
||||||
|
- Dalam 1 file tidak boleh ada duplikat `project_flock_id`.
|
||||||
|
- `project_flock_id` harus mengarah ke `project_flocks` yang `deleted_at IS NULL` dan `category = LAYING`.
|
||||||
|
|
||||||
|
## Contoh
|
||||||
|
|
||||||
|
Dry-run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
|
||||||
|
go run ./cmd/import-farm-depreciation-manual-inputs \
|
||||||
|
--file docs/templates/farm_depreciation_manual_inputs.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
|
||||||
|
go run ./cmd/import-farm-depreciation-manual-inputs \
|
||||||
|
--file /path/to/farm_depreciation_manual_inputs.xlsx \
|
||||||
|
--sheet manual_inputs \
|
||||||
|
--apply
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Umum
|
||||||
|
|
||||||
|
- `required header is missing`: header wajib tidak ditemukan.
|
||||||
|
- `must be a positive integer`: `project_flock_id` bukan integer valid.
|
||||||
|
- `must be greater than or equal to 0`: `total_cost` negatif.
|
||||||
|
- `must follow format YYYY-MM-DD`: `cutover_date` tidak sesuai format.
|
||||||
|
- `duplicate value ...`: `project_flock_id` duplikat dalam file yang sama.
|
||||||
|
- `must reference an active LAYING project_flock`: farm tidak valid untuk import ini.
|
||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user