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 }