Files
lti-api/cmd/import-adjustment-stock-prices/main.go
T

588 lines
13 KiB
Go

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
}