diff --git a/server/controllers/import.go b/server/controllers/import.go index 38a897d..fab641e 100644 --- a/server/controllers/import.go +++ b/server/controllers/import.go @@ -2,6 +2,7 @@ package controllers import ( "net/http" + "strconv" "github.com/akhilrex/hammond/service" "github.com/gin-gonic/gin" @@ -9,6 +10,7 @@ import ( func RegisteImportController(router *gin.RouterGroup) { router.POST("/import/fuelly", fuellyImport) + router.POST("/import/drivvo", drivvoImport) } func fuellyImport(c *gin.Context) { @@ -24,3 +26,28 @@ func fuellyImport(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{}) } + +func drivvoImport(c *gin.Context) { + bytes, err := getFileBytes(c, "file") + if err != nil { + c.JSON(http.StatusUnprocessableEntity, err) + return + } + vehicleId := c.PostForm("vehicleID") + if vehicleId == "" { + c.JSON(http.StatusUnprocessableEntity, "Missing Vehicle ID") + return + } + importLocation, err := strconv.ParseBool(c.PostForm("importLocation")) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, "Please include importLocation option.") + return + } + + errors := service.DrivvoImport(bytes, c.MustGet("userId").(string), vehicleId, importLocation) + if len(errors) > 0 { + c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors}) + return + } + c.JSON(http.StatusOK, gin.H{}) +} diff --git a/server/service/drivvoImportService.go b/server/service/drivvoImportService.go new file mode 100644 index 0000000..567c951 --- /dev/null +++ b/server/service/drivvoImportService.go @@ -0,0 +1,142 @@ +package service + +import ( + "bytes" + "encoding/csv" + "fmt" + "strconv" + "strings" + "time" + + "github.com/akhilrex/hammond/db" +) + +func DrivvoParseExpenses(content []byte, user *db.User, vehicle *db.Vehicle) ([]db.Expense, []string) { + expenseReader := csv.NewReader(bytes.NewReader(content)) + expenseReader.Comment = '#' + // Read headers (there is a trailing comma at the end, that's why we have to read the first line) + expenseReader.Read() + expenseReader.FieldsPerRecord = 6 + expenseRecords, err := expenseReader.ReadAll() + + var errors []string + if err != nil { + errors = append(errors, err.Error()) + println(err.Error()) + return nil, errors + } + + var expenses []db.Expense + for index, record := range expenseRecords { + date, err := time.Parse("2006-01-02 15:04:05", record[1]) + if err != nil { + errors = append(errors, "Found an invalid date/time at service/expense row "+strconv.Itoa(index+1)) + } + + totalCost, err := strconv.ParseFloat(record[2], 32) + if err != nil { + errors = append(errors, "Found and invalid total cost at service/expense row "+strconv.Itoa(index+1)) + } + + odometer, err := strconv.Atoi(record[0]) + if err != nil { + errors = append(errors, "Found an invalid odometer reading at service/expense row "+strconv.Itoa(index+1)) + } + + notes := fmt.Sprintf("Location: %s\nNotes: %s\n", record[4], record[5]) + + expenses = append(expenses, db.Expense{ + UserID: user.ID, + VehicleID: vehicle.ID, + Date: date, + OdoReading: odometer, + Amount: float32(totalCost), + ExpenseType: record[3], + Currency: user.Currency, + DistanceUnit: user.DistanceUnit, + Comments: notes, + Source: "Drivvo", + }) + } + + return expenses, errors +} + +func DrivvoParseRefuelings(content []byte, user *db.User, vehicle *db.Vehicle, importLocation bool) ([]db.Fillup, []string) { + refuelingReader := csv.NewReader(bytes.NewReader(content)) + refuelingReader.Comment = '#' + refuelingRecords, err := refuelingReader.ReadAll() + + var errors []string + if err != nil { + errors = append(errors, err.Error()) + println(err.Error()) + return nil, errors + } + + var fillups []db.Fillup + for index, record := range refuelingRecords { + // Skip column titles + if index == 0 { + continue + } + + date, err := time.Parse("2006-01-02 15:04:05", record[1]) + if err != nil { + errors = append(errors, "Found an invalid date/time at refuel row "+strconv.Itoa(index+1)) + } + + totalCost, err := strconv.ParseFloat(record[4], 32) + if err != nil { + errors = append(errors, "Found and invalid total cost at refuel row "+strconv.Itoa(index+1)) + } + + odometer, err := strconv.Atoi(record[0]) + if err != nil { + errors = append(errors, "Found an invalid odometer reading at refuel row "+strconv.Itoa(index+1)) + } + + location := "" + if importLocation { + location = record[17] + } + + pricePerUnit, err := strconv.ParseFloat(record[3], 32) + if err != nil { + unit := strings.ToLower(db.FuelUnitDetails[vehicle.FuelUnit].Key) + errors = append(errors, fmt.Sprintf("Found an invalid cost per %s at refuel row %d", unit, index+1)) + } + + quantity, err := strconv.ParseFloat(record[5], 32) + if err != nil { + errors = append(errors, "Found an invalid quantity at refuel row "+strconv.Itoa(index+1)) + } + + isTankFull := record[6] == "Yes" + + // Unfortunatly, drivvo doesn't expose this info in their export + fal := false + + notes := fmt.Sprintf("Reason: %s\nNotes: %s\nFuel: %s\n", record[18], record[19], record[2]) + + fillups = append(fillups, db.Fillup{ + VehicleID: vehicle.ID, + UserID: user.ID, + Date: date, + HasMissedFillup: &fal, + IsTankFull: &isTankFull, + FuelQuantity: float32(quantity), + PerUnitPrice: float32(pricePerUnit), + FillingStation: location, + OdoReading: odometer, + TotalAmount: float32(totalCost), + FuelUnit: vehicle.FuelUnit, + Currency: user.Currency, + DistanceUnit: user.DistanceUnit, + Comments: notes, + Source: "Drivvo", + }) + + } + return fillups, errors +} diff --git a/server/service/fuellyImportService.go b/server/service/fuellyImportService.go new file mode 100644 index 0000000..6b4da88 --- /dev/null +++ b/server/service/fuellyImportService.go @@ -0,0 +1,140 @@ +package service + +import ( + "bytes" + "encoding/csv" + "fmt" + "strconv" + "time" + + "github.com/akhilrex/hammond/db" + "github.com/leekchan/accounting" +) + +func FuellyParseAll(content []byte, userId string) ([]db.Fillup, []db.Expense, []string) { + stream := bytes.NewReader(content) + reader := csv.NewReader(stream) + records, err := reader.ReadAll() + + var errors []string + user, err := GetUserById(userId) + if err != nil { + errors = append(errors, err.Error()) + return nil, nil, errors + } + + vehicles, err := GetUserVehicles(userId) + if err != nil { + errors = append(errors, err.Error()) + return nil, nil, errors + } + + if err != nil { + errors = append(errors, err.Error()) + return nil, nil, errors + } + + var vehicleMap map[string]db.Vehicle = make(map[string]db.Vehicle) + for _, vehicle := range *vehicles { + vehicleMap[vehicle.Nickname] = vehicle + } + + var fillups []db.Fillup + var expenses []db.Expense + layout := "2006-01-02 15:04" + altLayout := "2006-01-02 3:04 PM" + + for index, record := range records { + if index == 0 { + continue + } + + var vehicle db.Vehicle + var ok bool + if vehicle, ok = vehicleMap[record[4]]; !ok { + errors = append(errors, "Found an unmapped vehicle entry at row "+strconv.Itoa(index+1)) + } + dateStr := record[2] + " " + record[3] + date, err := time.Parse(layout, dateStr) + if err != nil { + date, err = time.Parse(altLayout, dateStr) + } + if err != nil { + errors = append(errors, "Found an invalid date/time at row "+strconv.Itoa(index+1)) + } + + totalCostStr := accounting.UnformatNumber(record[9], 3, user.Currency) + totalCost64, err := strconv.ParseFloat(totalCostStr, 32) + if err != nil { + errors = append(errors, "Found an invalid total cost at row "+strconv.Itoa(index+1)) + } + + totalCost := float32(totalCost64) + odoStr := accounting.UnformatNumber(record[5], 0, user.Currency) + odoreading, err := strconv.Atoi(odoStr) + if err != nil { + errors = append(errors, "Found an invalid odo reading at row "+strconv.Itoa(index+1)) + } + location := record[12] + + //Create Fillup + if record[0] == "Gas" { + rateStr := accounting.UnformatNumber(record[7], 3, user.Currency) + ratet64, err := strconv.ParseFloat(rateStr, 32) + if err != nil { + errors = append(errors, "Found an invalid cost per gallon at row "+strconv.Itoa(index+1)) + } + rate := float32(ratet64) + + quantity64, err := strconv.ParseFloat(record[8], 32) + if err != nil { + errors = append(errors, "Found an invalid quantity at row "+strconv.Itoa(index+1)) + } + quantity := float32(quantity64) + + notes := fmt.Sprintf("Octane:%s\nGas Brand:%s\nLocation%s\nTags:%s\nPayment Type:%s\nTire Pressure:%s\nNotes:%s\nMPG:%s", + record[10], record[11], record[12], record[13], record[14], record[15], record[16], record[1], + ) + + isTankFull := record[6] == "Full" + fal := false + fillups = append(fillups, db.Fillup{ + VehicleID: vehicle.ID, + FuelUnit: vehicle.FuelUnit, + FuelQuantity: quantity, + PerUnitPrice: rate, + TotalAmount: totalCost, + OdoReading: odoreading, + IsTankFull: &isTankFull, + Comments: notes, + FillingStation: location, + HasMissedFillup: &fal, + UserID: userId, + Date: date, + Currency: user.Currency, + DistanceUnit: user.DistanceUnit, + Source: "Fuelly", + }) + + } + if record[0] == "Service" { + notes := fmt.Sprintf("Tags:%s\nPayment Type:%s\nNotes:%s", + record[13], record[14], record[16], + ) + expenses = append(expenses, db.Expense{ + VehicleID: vehicle.ID, + Amount: totalCost, + OdoReading: odoreading, + Comments: notes, + ExpenseType: record[17], + UserID: userId, + Currency: user.Currency, + Date: date, + DistanceUnit: user.DistanceUnit, + Source: "Fuelly", + }) + } + + } + return fillups, expenses, errors +} diff --git a/server/service/importService.go b/server/service/importService.go index dbd5eab..b8850c4 100644 --- a/server/service/importService.go +++ b/server/service/importService.go @@ -2,144 +2,12 @@ package service import ( "bytes" - "encoding/csv" - "fmt" - "strconv" - "time" "github.com/akhilrex/hammond/db" - "github.com/leekchan/accounting" ) -func FuellyImport(content []byte, userId string) []string { - stream := bytes.NewReader(content) - reader := csv.NewReader(stream) - records, err := reader.ReadAll() - +func WriteToDB(fillups []db.Fillup, expenses []db.Expense) []string { var errors []string - if err != nil { - errors = append(errors, err.Error()) - return errors - } - - vehicles, err := GetUserVehicles(userId) - if err != nil { - errors = append(errors, err.Error()) - return errors - } - user, err := GetUserById(userId) - - if err != nil { - errors = append(errors, err.Error()) - return errors - } - - var vehicleMap map[string]db.Vehicle = make(map[string]db.Vehicle) - for _, vehicle := range *vehicles { - vehicleMap[vehicle.Nickname] = vehicle - } - - var fillups []db.Fillup - var expenses []db.Expense - layout := "2006-01-02 15:04" - altLayout := "2006-01-02 3:04 PM" - - for index, record := range records { - if index == 0 { - continue - } - - var vehicle db.Vehicle - var ok bool - if vehicle, ok = vehicleMap[record[4]]; !ok { - errors = append(errors, "Found an unmapped vehicle entry at row "+strconv.Itoa(index+1)) - } - dateStr := record[2] + " " + record[3] - date, err := time.Parse(layout, dateStr) - if err != nil { - date, err = time.Parse(altLayout, dateStr) - } - if err != nil { - errors = append(errors, "Found an invalid date/time at row "+strconv.Itoa(index+1)) - } - - totalCostStr := accounting.UnformatNumber(record[9], 3, user.Currency) - totalCost64, err := strconv.ParseFloat(totalCostStr, 32) - if err != nil { - errors = append(errors, "Found an invalid total cost at row "+strconv.Itoa(index+1)) - } - - totalCost := float32(totalCost64) - odoStr := accounting.UnformatNumber(record[5], 0, user.Currency) - odoreading, err := strconv.Atoi(odoStr) - if err != nil { - errors = append(errors, "Found an invalid odo reading at row "+strconv.Itoa(index+1)) - } - location := record[12] - - //Create Fillup - if record[0] == "Gas" { - rateStr := accounting.UnformatNumber(record[7], 3, user.Currency) - ratet64, err := strconv.ParseFloat(rateStr, 32) - if err != nil { - errors = append(errors, "Found an invalid cost per gallon at row "+strconv.Itoa(index+1)) - } - rate := float32(ratet64) - - quantity64, err := strconv.ParseFloat(record[8], 32) - if err != nil { - errors = append(errors, "Found an invalid quantity at row "+strconv.Itoa(index+1)) - } - quantity := float32(quantity64) - - notes := fmt.Sprintf("Octane:%s\nGas Brand:%s\nLocation%s\nTags:%s\nPayment Type:%s\nTire Pressure:%s\nNotes:%s\nMPG:%s", - record[10], record[11], record[12], record[13], record[14], record[15], record[16], record[1], - ) - - isTankFull := record[6] == "Full" - fal := false - fillups = append(fillups, db.Fillup{ - VehicleID: vehicle.ID, - FuelUnit: vehicle.FuelUnit, - FuelQuantity: quantity, - PerUnitPrice: rate, - TotalAmount: totalCost, - OdoReading: odoreading, - IsTankFull: &isTankFull, - Comments: notes, - FillingStation: location, - HasMissedFillup: &fal, - UserID: userId, - Date: date, - Currency: user.Currency, - DistanceUnit: user.DistanceUnit, - Source: "Fuelly", - }) - - } - if record[0] == "Service" { - notes := fmt.Sprintf("Tags:%s\nPayment Type:%s\nNotes:%s", - record[13], record[14], record[16], - ) - expenses = append(expenses, db.Expense{ - VehicleID: vehicle.ID, - Amount: totalCost, - OdoReading: odoreading, - Comments: notes, - ExpenseType: record[17], - UserID: userId, - Currency: user.Currency, - Date: date, - DistanceUnit: user.DistanceUnit, - Source: "Fuelly", - }) - } - - } - if len(errors) != 0 { - return errors - } - tx := db.DB.Begin() defer func() { if r := recover(); r != nil { @@ -150,19 +18,90 @@ func FuellyImport(content []byte, userId string) []string { errors = append(errors, err.Error()) return errors } - if err := tx.Create(&fillups).Error; err != nil { - tx.Rollback() - errors = append(errors, err.Error()) - return errors + if fillups != nil { + if err := tx.Create(&fillups).Error; err != nil { + tx.Rollback() + errors = append(errors, err.Error()) + return errors + } } - if err := tx.Create(&expenses).Error; err != nil { - tx.Rollback() - errors = append(errors, err.Error()) - return errors + if expenses != nil { + if err := tx.Create(&expenses).Error; err != nil { + tx.Rollback() + errors = append(errors, err.Error()) + return errors + } } - err = tx.Commit().Error + err := tx.Commit().Error if err != nil { errors = append(errors, err.Error()) } return errors + +} + +func DrivvoImport(content []byte, userId string, vehicleId string, importLocation bool) []string { + var errors []string + user, err := GetUserById(userId) + if err != nil { + errors = append(errors, err.Error()) + return errors + } + + vehicle, err := GetVehicleById(vehicleId) + if err != nil { + errors = append(errors, err.Error()) + return errors + } + + endParseIndex := bytes.Index(content, []byte("#Income")) + if endParseIndex == -1 { + endParseIndex = bytes.Index(content, []byte("#Route")) + if endParseIndex == -1 { + endParseIndex = len(content) + } + + } + + serviceEndIndex := bytes.Index(content, []byte("#Expense")) + if serviceEndIndex == -1 { + serviceEndIndex = endParseIndex + } + + refuelEndIndex := bytes.Index(content, []byte("#Service")) + if refuelEndIndex == -1 { + refuelEndIndex = serviceEndIndex + } + + var fillups []db.Fillup + fillups, errors = DrivvoParseRefuelings(content[:refuelEndIndex], user, vehicle, importLocation) + + var allExpenses []db.Expense + services, parseErrors := DrivvoParseExpenses(content[refuelEndIndex:serviceEndIndex], user, vehicle) + if parseErrors != nil { + errors = append(errors, parseErrors...) + } + allExpenses = append(allExpenses, services...) + + expenses, parseErrors := DrivvoParseExpenses(content[serviceEndIndex:endParseIndex], user, vehicle) + if parseErrors != nil { + errors = append(errors, parseErrors...) + } + + allExpenses = append(allExpenses, expenses...) + + if len(errors) != 0 { + return errors + } + + return WriteToDB(fillups, allExpenses) +} + +func FuellyImport(content []byte, userId string) []string { + fillups, expenses, errors := FuellyParseAll(content, userId) + if len(errors) != 0 { + return errors + } + + return WriteToDB(fillups, expenses) } diff --git a/ui/src/router/routes.js b/ui/src/router/routes.js index 82495f7..011de78 100644 --- a/ui/src/router/routes.js +++ b/ui/src/router/routes.js @@ -410,6 +410,15 @@ export default [ }, props: (route) => ({ user: store.state.auth.currentUser || {} }), }, + { + path: '/import/drivvo', + name: 'import-drivvo', + component: () => lazyLoadView(import('@views/import-drivvo.vue')), + meta: { + authRequired: true, + }, + props: (route) => ({ user: store.state.auth.currentUser || {} }), + }, { path: '/logout', name: 'logout', diff --git a/ui/src/router/views/import-drivvo.vue b/ui/src/router/views/import-drivvo.vue new file mode 100644 index 0000000..143b2d5 --- /dev/null +++ b/ui/src/router/views/import-drivvo.vue @@ -0,0 +1,172 @@ + + + diff --git a/ui/src/router/views/import-fuelly.vue b/ui/src/router/views/import-fuelly.vue index 3944d0d..f536184 100644 --- a/ui/src/router/views/import-fuelly.vue +++ b/ui/src/router/views/import-fuelly.vue @@ -58,6 +58,7 @@ export default { duration: 3000, }) this.file = null + setTimeout(() => this.$router.push({ name: 'home' }), 1000) }) .catch((ex) => { this.$buefy.toast.open({ @@ -108,7 +109,7 @@ export default {
- + {{ uploadButtonLabel }} diff --git a/ui/src/router/views/import.vue b/ui/src/router/views/import.vue index b78a077..40597e6 100644 --- a/ui/src/router/views/import.vue +++ b/ui/src/router/views/import.vue @@ -26,11 +26,22 @@ export default { >
-
-

Fuelly

-

{{ $t('importcsv', { 'name': 'Fuelly' }) }}

-
- {{ $t('import') }} +
+
+

Fuelly

+

If you have been using Fuelly to store your vehicle data, export the CSV file from Fuelly and click here to import.

+
+ {{ $t('import') }} +
+
+ +
+
+

Drivvo

+

{{ $t('importcsv', { 'name': 'Fuelly' }) }}

+
+ {{ $t('import') }} +