From f98c73f569562854da66392f52bcb86e13346d5c Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 20 Apr 2026 00:03:59 +0700 Subject: [PATCH] add command normalize data seed standar and price adjustment stocks --- cmd/import-adjustment-stock-prices/main.go | 587 ++++++++++++++++++ .../main_test.go | 362 +++++++++++ cmd/run-sql-file/main.go | 75 +++ docs/templates/adjustment_stock_prices.xlsx | Bin 0 -> 11232 bytes docs/templates/~$adjustment_stock_prices.xlsx | Bin 0 -> 165 bytes internal/modules/dashboards/module.go | 6 +- .../dashboards/services/dashboard.service.go | 4 +- .../repports/services/repport.service.go | 4 +- 8 files changed, 1031 insertions(+), 7 deletions(-) create mode 100644 cmd/import-adjustment-stock-prices/main.go create mode 100644 cmd/import-adjustment-stock-prices/main_test.go create mode 100644 cmd/run-sql-file/main.go create mode 100644 docs/templates/adjustment_stock_prices.xlsx create mode 100644 docs/templates/~$adjustment_stock_prices.xlsx diff --git a/cmd/import-adjustment-stock-prices/main.go b/cmd/import-adjustment-stock-prices/main.go new file mode 100644 index 00000000..e4092e79 --- /dev/null +++ b/cmd/import-adjustment-stock-prices/main.go @@ -0,0 +1,587 @@ +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" + "gorm.io/gorm" +) + +type importOptions struct { + FilePath string + Sheet string + Apply bool +} + +type headerIndexes struct { + AdjustmentID int + Weight int +} + +type adjustmentPriceImportRow struct { + RowNumber int + AdjustmentID uint + Weight float64 +} + +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 adjustmentResolver interface { + ResolveExistingAdjustmentIDs(ctx context.Context, adjustmentIDs []uint) (map[uint]struct{}, error) +} + +type dbAdjustmentResolver struct { + db *gorm.DB +} + +type adjustmentPriceStore interface { + UpdatePrice(ctx context.Context, adjustmentID uint, price float64) (bool, error) +} + +type txRunner interface { + InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error +} + +type dbTxRunner struct { + db *gorm.DB +} + +type dbAdjustmentPriceStore struct { + db *gorm.DB +} + +type applyRowResult struct { + RowNumber int + AdjustmentID uint + Price float64 + 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 := parseAdjustmentPriceFile(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 := dbAdjustmentResolver{db: db} + + existingAdjustmentIDs, err := resolver.ResolveExistingAdjustmentIDs(ctx, collectAdjustmentIDs(rows)) + if err != nil { + log.Fatalf("failed checking adjustment_id against adjustment_stocks: %v", err) + } + + processableRows, skippedRows := splitRowsByExistingIDs(rows, existingAdjustmentIDs) + issues := append([]validationIssue{}, parseIssues...) + 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.Printf("Rows processable: %d\n", len(processableRows)) + fmt.Printf("Rows skipped_missing: %d\n", len(skippedRows)) + fmt.Println() + + if len(processableRows) > 0 { + printPlanRows(processableRows) + } + if len(skippedRows) > 0 { + printSkippedRows(skippedRows) + } + if len(processableRows) > 0 || len(skippedRows) > 0 { + 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 processable=%d skipped_missing=%d applied=0 failed=%d\n", + len(rows), + len(processableRows), + len(skippedRows), + len(issues), + ) + os.Exit(1) + } + + if !opts.Apply { + fmt.Printf( + "Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=0\n", + len(rows), + len(processableRows), + len(skippedRows), + ) + return + } + + results, err := applyIfRequested(ctx, true, dbTxRunner{db: db}, processableRows) + if err != nil { + log.Fatalf("apply failed: %v", err) + } + + for _, result := range results { + fmt.Printf( + "DONE row=%d adjustment_id=%d price=%.3f status=%s\n", + result.RowNumber, + result.AdjustmentID, + result.Price, + applyStatus(result.Changed), + ) + } + + appliedCount := countChangedRows(results) + if len(results) > 0 { + fmt.Println() + } + fmt.Printf( + "Summary: planned=%d processable=%d skipped_missing=%d applied=%d failed=0\n", + len(rows), + len(processableRows), + len(skippedRows), + appliedCount, + ) +} + +func parseAdjustmentPriceFile( + filePath string, + requestedSheet string, +) (string, []adjustmentPriceImportRow, []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 + } + + rowsByAdjustmentID := make(map[uint]adjustmentPriceImportRow) + issues := make([]validationIssue, 0) + + for idx := 1; idx < len(allRows); idx++ { + rowNumber := idx + 1 + rawRow := allRows[idx] + + if isRowEmpty(rawRow) { + continue + } + + parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes) + if len(rowIssues) > 0 { + issues = append(issues, rowIssues...) + continue + } + + rowsByAdjustmentID[parsed.AdjustmentID] = *parsed + } + + rows := make([]adjustmentPriceImportRow, 0, len(rowsByAdjustmentID)) + for _, row := range rowsByAdjustmentID { + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].RowNumber < rows[j].RowNumber + }) + + 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{AdjustmentID: -1, Weight: -1} + issues := make([]validationIssue, 0) + + for idx, raw := range headerRow { + header := normalizeHeader(raw) + if header == "" { + continue + } + + switch header { + case "adjustment_id": + if indexes.AdjustmentID >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header adjustment_id"}) + } + indexes.AdjustmentID = idx + case "weight": + if indexes.Weight >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header weight"}) + } + indexes.Weight = idx + } + } + + if indexes.AdjustmentID < 0 { + issues = append(issues, validationIssue{Field: "adjustment_id", Message: "required header is missing"}) + } + if indexes.Weight < 0 { + issues = append(issues, validationIssue{Field: "weight", Message: "required header is missing"}) + } + + return indexes, issues +} + +func parseDataRow( + rawRow []string, + rowNumber int, + indexes headerIndexes, +) (*adjustmentPriceImportRow, []validationIssue) { + issues := make([]validationIssue, 0) + + adjustmentIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.AdjustmentID)) + adjustmentID, err := parsePositiveUint(adjustmentIDRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "adjustment_id", Message: err.Error()}) + } + + weightRaw := strings.TrimSpace(cellValue(rawRow, indexes.Weight)) + weight, err := parseNonNegativeFloat(weightRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "weight", Message: err.Error()}) + } + + if len(issues) > 0 { + return nil, issues + } + + return &adjustmentPriceImportRow{ + RowNumber: rowNumber, + AdjustmentID: adjustmentID, + Weight: weight, + }, 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 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 collectAdjustmentIDs(rows []adjustmentPriceImportRow) []uint { + ids := make([]uint, 0, len(rows)) + seen := make(map[uint]struct{}, len(rows)) + for _, row := range rows { + if row.AdjustmentID == 0 { + continue + } + if _, exists := seen[row.AdjustmentID]; exists { + continue + } + seen[row.AdjustmentID] = struct{}{} + ids = append(ids, row.AdjustmentID) + } + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + return ids +} + +func (r dbAdjustmentResolver) ResolveExistingAdjustmentIDs( + ctx context.Context, + adjustmentIDs []uint, +) (map[uint]struct{}, error) { + result := make(map[uint]struct{}) + if len(adjustmentIDs) == 0 { + return result, nil + } + + type adjustmentIDRow struct { + ID uint `gorm:"column:id"` + } + + rows := make([]adjustmentIDRow, 0, len(adjustmentIDs)) + if err := r.db.WithContext(ctx). + Table("adjustment_stocks"). + Select("id"). + Where("id IN ?", adjustmentIDs). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ID] = struct{}{} + } + + return result, nil +} + +func splitRowsByExistingIDs( + rows []adjustmentPriceImportRow, + existing map[uint]struct{}, +) ([]adjustmentPriceImportRow, []adjustmentPriceImportRow) { + processable := make([]adjustmentPriceImportRow, 0, len(rows)) + skipped := make([]adjustmentPriceImportRow, 0) + + for _, row := range rows { + if _, exists := existing[row.AdjustmentID]; exists { + processable = append(processable, row) + continue + } + skipped = append(skipped, row) + } + + return processable, skipped +} + +func printPlanRows(rows []adjustmentPriceImportRow) { + for _, row := range rows { + fmt.Printf( + "PLAN row=%d adjustment_id=%d price=%.3f\n", + row.RowNumber, + row.AdjustmentID, + row.Weight, + ) + } +} + +func printSkippedRows(rows []adjustmentPriceImportRow) { + for _, row := range rows { + fmt.Printf( + "SKIP row=%d adjustment_id=%d reason=adjustment_id not found\n", + row.RowNumber, + row.AdjustmentID, + ) + } +} + +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 []adjustmentPriceImportRow, +) ([]applyRowResult, error) { + if !apply || len(rows) == 0 { + return nil, nil + } + return applyImportRows(ctx, runner, rows) +} + +func applyImportRows( + ctx context.Context, + runner txRunner, + rows []adjustmentPriceImportRow, +) ([]applyRowResult, error) { + results := make([]applyRowResult, 0, len(rows)) + + err := runner.InTx(ctx, func(store adjustmentPriceStore) error { + for _, row := range rows { + changed, err := store.UpdatePrice(ctx, row.AdjustmentID, row.Weight) + if err != nil { + return fmt.Errorf("row %d adjustment_id=%d update failed: %w", row.RowNumber, row.AdjustmentID, err) + } + + results = append(results, applyRowResult{ + RowNumber: row.RowNumber, + AdjustmentID: row.AdjustmentID, + Price: row.Weight, + Changed: changed, + }) + } + return nil + }) + if err != nil { + return nil, err + } + + return results, nil +} + +func (r dbTxRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return fn(dbAdjustmentPriceStore{db: tx}) + }) +} + +func (s dbAdjustmentPriceStore) UpdatePrice( + ctx context.Context, + adjustmentID uint, + price float64, +) (bool, error) { + result := s.db.WithContext(ctx).Exec(` + UPDATE adjustment_stocks + SET price = ?, + updated_at = NOW() + WHERE id = ? + AND price IS DISTINCT FROM ? + `, price, adjustmentID, price) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, 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 _, result := range results { + if result.Changed { + count++ + } + } + return count +} diff --git a/cmd/import-adjustment-stock-prices/main_test.go b/cmd/import-adjustment-stock-prices/main_test.go new file mode 100644 index 00000000..121ddc81 --- /dev/null +++ b/cmd/import-adjustment-stock-prices/main_test.go @@ -0,0 +1,362 @@ +package main + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestParseAdjustmentPriceFile_ValidSingleRow(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "weight"}, + [][]string{{"101", "12.345"}}, + ) + + sheet, rows, issues, err := parseAdjustmentPriceFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if sheet != "adjustment_prices" { + t.Fatalf("expected selected sheet adjustment_prices, 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].AdjustmentID != 101 { + t.Fatalf("expected adjustment_id 101, got %d", rows[0].AdjustmentID) + } + if rows[0].Weight != 12.345 { + t.Fatalf("expected weight 12.345, got %v", rows[0].Weight) + } +} + +func TestParseAdjustmentPriceFile_ValidMultiRow(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{" Adjustment_ID ", "WEIGHT"}, + [][]string{{"101", "10"}, {"102", "11.5"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(filePath, "adjustment_prices") + 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)) + } +} + +func TestParseAdjustmentPriceFile_MissingRequiredHeader(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "price"}, + [][]string{{"101", "12"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(filePath, "") + 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, "weight", "required header is missing") { + t.Fatalf("expected missing weight header issue, got %+v", issues) + } +} + +func TestParseAdjustmentPriceFile_InvalidAdjustmentID(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "weight"}, + [][]string{{"abc", "10"}, {"0", "12"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(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, "adjustment_id", "must be a positive integer") { + t.Fatalf("expected non numeric adjustment_id issue, got %+v", issues) + } + if !hasIssue(issues, 3, "adjustment_id", "must be greater than 0") { + t.Fatalf("expected adjustment_id >0 issue, got %+v", issues) + } +} + +func TestParseAdjustmentPriceFile_InvalidWeight(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "weight"}, + [][]string{{"101", "abc"}, {"102", "-1"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(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, "weight", "must be numeric") { + t.Fatalf("expected weight numeric issue, got %+v", issues) + } + if !hasIssue(issues, 3, "weight", "must be greater than or equal to 0") { + t.Fatalf("expected weight >=0 issue, got %+v", issues) + } +} + +func TestParseAdjustmentPriceFile_DuplicateAdjustmentID_LastRowWins(t *testing.T) { + filePath := createWorkbook( + t, + "adjustment_prices", + []string{"adjustment_id", "weight"}, + [][]string{{"101", "10"}, {"102", "20"}, {"101", "30"}}, + ) + + _, rows, issues, err := parseAdjustmentPriceFile(filePath, "") + 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 deduped rows, got %d", len(rows)) + } + + row101, ok := findRowByAdjustmentID(rows, 101) + if !ok { + t.Fatalf("expected adjustment_id 101 to exist, got %+v", rows) + } + if row101.Weight != 30 { + t.Fatalf("expected duplicate adjustment_id to keep last weight 30, got %v", row101.Weight) + } + if row101.RowNumber != 4 { + t.Fatalf("expected duplicate adjustment_id to keep last row number 4, got %d", row101.RowNumber) + } +} + +func TestSplitRowsByExistingIDs_SkipMissing(t *testing.T) { + rows := []adjustmentPriceImportRow{ + {RowNumber: 2, AdjustmentID: 101, Weight: 10}, + {RowNumber: 3, AdjustmentID: 102, Weight: 11}, + {RowNumber: 4, AdjustmentID: 103, Weight: 12}, + } + existing := map[uint]struct{}{101: {}, 103: {}} + + processable, skipped := splitRowsByExistingIDs(rows, existing) + if len(processable) != 2 { + t.Fatalf("expected 2 processable rows, got %d", len(processable)) + } + if len(skipped) != 1 { + t.Fatalf("expected 1 skipped row, got %d", len(skipped)) + } + if skipped[0].AdjustmentID != 102 { + t.Fatalf("expected adjustment_id 102 skipped, got %+v", skipped) + } +} + +func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) { + runner := &fakeTransactionRunner{} + rows := []adjustmentPriceImportRow{{RowNumber: 2, AdjustmentID: 101, Weight: 10}} + + results, err := applyIfRequested(context.Background(), false, runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if results != nil { + t.Fatalf("expected nil results on dry-run, got %+v", results) + } + if runner.txCalls != 0 { + t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls) + } +} + +func TestApplyImportRows_Success(t *testing.T) { + runner := &fakeTransactionRunner{ + changedByID: map[uint]bool{101: true, 102: false}, + } + rows := []adjustmentPriceImportRow{ + {RowNumber: 2, AdjustmentID: 101, Weight: 10}, + {RowNumber: 3, AdjustmentID: 102, Weight: 11}, + } + + results, 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.committedCalls) != 2 { + t.Fatalf("expected 2 committed updates, got %d", len(runner.committedCalls)) + } + if len(results) != 2 { + t.Fatalf("expected 2 row results, got %d", len(results)) + } + if !results[0].Changed || results[1].Changed { + t.Fatalf("unexpected changed flags: %+v", results) + } +} + +func TestApplyImportRows_RollbackOnError(t *testing.T) { + runner := &fakeTransactionRunner{ + errByID: map[uint]error{102: errors.New("boom")}, + } + rows := []adjustmentPriceImportRow{ + {RowNumber: 2, AdjustmentID: 101, Weight: 10}, + {RowNumber: 3, AdjustmentID: 102, Weight: 11}, + } + + _, err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatal("expected error due to update failure") + } + if !strings.Contains(err.Error(), "row 3 adjustment_id=102 update failed") { + t.Fatalf("unexpected error message: %v", err) + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) + } + if len(runner.committedCalls) != 0 { + t.Fatalf("expected no committed updates on rollback, got %d", len(runner.committedCalls)) + } +} + +func createWorkbook(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(), "adjustment_prices.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 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 +} + +func findRowByAdjustmentID(rows []adjustmentPriceImportRow, adjustmentID uint) (adjustmentPriceImportRow, bool) { + for _, row := range rows { + if row.AdjustmentID == adjustmentID { + return row, true + } + } + return adjustmentPriceImportRow{}, false +} + +type updateCall struct { + adjustmentID uint + price float64 +} + +type fakeAdjustmentPriceStore struct { + changedByID map[uint]bool + errByID map[uint]error + calls []updateCall +} + +func (s *fakeAdjustmentPriceStore) UpdatePrice(_ context.Context, adjustmentID uint, price float64) (bool, error) { + s.calls = append(s.calls, updateCall{adjustmentID: adjustmentID, price: price}) + if err, exists := s.errByID[adjustmentID]; exists { + return false, fmt.Errorf("forced update failure for adjustment_id=%d: %w", adjustmentID, err) + } + if changed, exists := s.changedByID[adjustmentID]; exists { + return changed, nil + } + return true, nil +} + +type fakeTransactionRunner struct { + txCalls int + changedByID map[uint]bool + errByID map[uint]error + committedCalls []updateCall +} + +func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error { + r.txCalls++ + + txStore := &fakeAdjustmentPriceStore{ + changedByID: r.changedByID, + errByID: r.errByID, + calls: make([]updateCall, 0), + } + + if err := fn(txStore); err != nil { + return err + } + + r.committedCalls = append(r.committedCalls, txStore.calls...) + return nil +} + +var _ txRunner = (*fakeTransactionRunner)(nil) +var _ adjustmentPriceStore = (*fakeAdjustmentPriceStore)(nil) diff --git a/cmd/run-sql-file/main.go b/cmd/run-sql-file/main.go new file mode 100644 index 00000000..6c8cb094 --- /dev/null +++ b/cmd/run-sql-file/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gorm.io/gorm" +) + +type options struct { + FilePath string + Apply bool +} + +func main() { + var opts options + flag.StringVar(&opts.FilePath, "file", "", "Path to .sql file (required)") + flag.BoolVar(&opts.Apply, "apply", false, "Apply SQL to database. If false, run as dry-run") + flag.Parse() + + opts.FilePath = strings.TrimSpace(opts.FilePath) + if opts.FilePath == "" { + log.Fatal("--file is required") + } + + sqlContent, err := readSQLFile(opts.FilePath) + if err != nil { + log.Fatalf("failed reading sql file: %v", err) + } + + mode := "dry-run" + if opts.Apply { + mode = "apply" + } + fmt.Printf("Mode: %s\n", mode) + fmt.Printf("File: %s\n", opts.FilePath) + fmt.Printf("SQL bytes: %d\n", len(sqlContent)) + + if !opts.Apply { + fmt.Println("Dry-run only. Add --apply to execute the SQL file.") + return + } + + db := database.Connect(config.DBHost, config.DBName) + if err := executeSQL(db, sqlContent); err != nil { + log.Fatalf("failed executing sql file: %v", err) + } + + fmt.Println("DONE: SQL executed successfully") +} + +func readSQLFile(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + + sql := strings.TrimSpace(strings.TrimPrefix(string(raw), "\ufeff")) + if sql == "" { + return "", fmt.Errorf("sql file is empty") + } + + return sql, nil +} + +func executeSQL(db *gorm.DB, sql string) error { + return db.Transaction(func(tx *gorm.DB) error { + return tx.Exec(sql).Error + }) +} diff --git a/docs/templates/adjustment_stock_prices.xlsx b/docs/templates/adjustment_stock_prices.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3e1ce3deca6f1a558fb31bfbad3179bc12d10f61 GIT binary patch literal 11232 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