-
Notifications
You must be signed in to change notification settings - Fork 0
/
drivly_valuation_service.go
314 lines (270 loc) · 11.1 KB
/
drivly_valuation_service.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
package services
import (
"context"
"encoding/json"
"fmt"
"github.com/ericlagergren/decimal"
"github.com/volatiletech/sqlboiler/v4/types"
"github.com/tidwall/gjson"
"time"
pb "github.com/DIMO-Network/device-data-api/pkg/grpc"
pbdeviceapi "github.com/DIMO-Network/devices-api/pkg/grpc"
"github.com/DIMO-Network/shared/db"
"github.com/DIMO-Network/valuations-api/internal/config"
core "github.com/DIMO-Network/valuations-api/internal/core/models"
"github.com/DIMO-Network/valuations-api/internal/infrastructure/db/models"
"github.com/rs/zerolog"
"github.com/segmentio/ksuid"
"github.com/volatiletech/null/v8"
"github.com/volatiletech/sqlboiler/v4/boil"
"github.com/volatiletech/sqlboiler/v4/queries/qm"
)
//go:generate mockgen -source drivly_valuation_service.go -destination mocks/drivly_valuation_service_mock.go
type DrivlyValuationService interface {
PullValuation(ctx context.Context, userDeviceID string, tokenID uint64, deviceDefinitionID, vin string) (core.DataPullStatusEnum, error)
PullOffer(ctx context.Context, userDeviceID string, tokenID uint64, vin string) (core.DataPullStatusEnum, error)
}
type drivlyValuationService struct {
dbs func() *db.ReaderWriter
ddSvc DeviceDefinitionsAPIService
drivlySvc DrivlyAPIService
udSvc UserDeviceAPIService
geoSvc GoogleGeoAPIService
uddSvc UserDeviceDataAPIService
log *zerolog.Logger
}
func NewDrivlyValuationService(DBS func() *db.ReaderWriter, log *zerolog.Logger, settings *config.Settings, ddSvc DeviceDefinitionsAPIService, uddSvc UserDeviceDataAPIService, udSvc UserDeviceAPIService) DrivlyValuationService {
return &drivlyValuationService{
dbs: DBS,
log: log,
drivlySvc: NewDrivlyAPIService(settings, DBS),
ddSvc: ddSvc,
geoSvc: NewGoogleGeoAPIService(settings),
uddSvc: uddSvc,
udSvc: udSvc,
}
}
// PullValuation performs a data pull for a vehicle valuation. It retrieves pricing and
// other relevant data for a given VIN. Not necessary for the userDevice to exist, VIN is what matters
func (d *drivlyValuationService) PullValuation(ctx context.Context, userDeviceID string, tokenID uint64, deviceDefinitionID, vin string) (core.DataPullStatusEnum, error) {
const repullWindow = time.Hour * 24 * 14
if len(vin) != 17 {
return core.ErrorDataPullStatus, fmt.Errorf("invalid VIN %s", vin)
}
deviceDef, err := d.ddSvc.GetDeviceDefinitionByID(ctx, deviceDefinitionID)
if err != nil {
return core.ErrorDataPullStatus, err
}
localLog := d.log.With().Str("vin", vin).Str("device_definition_id", deviceDefinitionID).Str("user_device_id", userDeviceID).Logger()
// determine if want to pull pricing data
existingPricingData, _ := models.Valuations(
models.ValuationWhere.Vin.EQ(vin),
models.ValuationWhere.DrivlyPricingMetadata.IsNotNull(),
qm.OrderBy("updated_at desc"), qm.Limit(1)).
One(context.Background(), d.dbs().Writer)
// just return if already pulled recently for this VIN, but still need to insert never pulled vin - should be uncommon scenario
if existingPricingData != nil && existingPricingData.UpdatedAt.Add(repullWindow).After(time.Now()) {
localLog.Info().Msgf("already pulled pricing data for vin %s, skipping", vin)
return core.SkippedDataPullStatus, nil
}
// by this point we know we might need to insert drivly valuation
valuation := &models.Valuation{
ID: ksuid.New().String(),
DeviceDefinitionID: null.StringFrom(deviceDef.DeviceDefinitionId),
Vin: vin,
UserDeviceID: null.StringFrom(userDeviceID),
TokenID: types.NewNullDecimal(decimal.New(int64(tokenID), 0)),
}
// get mileage for the drivly request
userDeviceData, err := d.uddSvc.GetVehicleRawData(ctx, userDeviceID)
if err != nil {
// just warn if can't get data
localLog.Warn().Err(err).Msgf("could not find any user device data to obtain mileage or location - continuing without")
}
deviceMileage := getDeviceMileage(userDeviceData, int(deviceDef.Type.Year), time.Now().Year())
if deviceMileage == 0 {
localLog.Warn().Msg("vehicle mileage found was 0 for valuation pull request")
}
reqData := ValuationRequestData{
Mileage: &deviceMileage,
}
// handle postal code information to send to drivly and store for future use
userDevice, err := d.udSvc.GetUserDevice(ctx, userDeviceID)
if err != nil {
localLog.Err(err).Msg("failed to get user device")
}
if userDevice != nil && userDevice.PostalCode == "" && userDeviceData != nil && len(userDeviceData.Items) > 0 {
// need to geodecode the postal code
lat := gjson.GetBytes(userDeviceData.Items[0].SignalsJsonData, "latitude.value").Float()
long := gjson.GetBytes(userDeviceData.Items[0].SignalsJsonData, "longitude.value").Float()
localLog.Info().Msgf("lat long found: %f, %f", lat, long)
if lat > 0 && long > 0 {
gl, err := d.geoSvc.GeoDecodeLatLong(lat, long)
if err != nil {
localLog.Err(err).Msgf("failed to GeoDecode lat long %f, %f", lat, long)
}
if gl != nil {
userDevice.PostalCode = gl.PostalCode
// update UD, ignore if fails doesn't matter
err := d.udSvc.UpdateUserDeviceMetadata(ctx, &pbdeviceapi.UpdateUserDeviceMetadataRequest{
UserDeviceId: userDeviceID,
PostalCode: &gl.PostalCode,
GeoDecodedCountry: &gl.Country,
GeoDecodedStateProv: &gl.AdminAreaLevel1,
})
if err != nil {
localLog.Err(err).Msgf("failed to update user device metadata for postal code")
}
localLog.Info().Msgf("GeoDecoded a lat long: %+v", gl)
}
}
}
if userDevice != nil && userDevice.PostalCode != "" {
reqData.ZipCode = &userDevice.PostalCode
}
_ = valuation.RequestMetadata.Marshal(reqData)
pricing, err := d.drivlySvc.GetVINPricing(vin, &reqData)
if err == nil {
_ = valuation.DrivlyPricingMetadata.Marshal(pricing)
}
// check on edmunds data so we can get the style id
edmundsExists, _ := models.Valuations(models.ValuationWhere.UserDeviceID.EQ(null.StringFrom(userDeviceID)),
models.ValuationWhere.EdmundsMetadata.IsNotNull()).Exists(ctx, d.dbs().Reader)
if !edmundsExists {
// extra optional data that only needs to be pulled once.
edmunds, err := d.drivlySvc.GetEdmundsByVIN(vin) // this is source data that will only be available after pulling vin + pricing
if err == nil {
_ = valuation.EdmundsMetadata.Marshal(edmunds)
}
// fill in edmunds style_id in our user_device if it exists and not already set. None of these seen as bad errors so just logs
if edmunds != nil && userDevice != nil && userDevice.DeviceStyleId == nil {
d.setUserDeviceStyleFromEdmunds(ctx, edmunds, userDevice)
localLog.Info().Msgf("set device_style_id for userDevice id %s", userDeviceID)
} else {
localLog.Warn().Msgf("could not set edmunds style id. edmunds data exists: %v", edmunds != nil)
}
}
err = valuation.Insert(ctx, d.dbs().Writer, boil.Infer())
if err != nil {
return core.ErrorDataPullStatus, err
}
//defer appmetrics.DrivlyIngestTotalOps.Inc()
return core.PulledValuationDrivlyStatus, nil
}
func (d *drivlyValuationService) PullOffer(ctx context.Context, userDeviceID string, tokenID uint64, vin string) (core.DataPullStatusEnum, error) {
// make sure userdevice exists
userDevice, err := d.udSvc.GetUserDevice(ctx, userDeviceID)
if err != nil {
return core.ErrorDataPullStatus, err
}
if len(vin) != 17 {
return core.ErrorDataPullStatus, fmt.Errorf("invalid VIN %s", vin)
}
localLog := d.log.With().Str("vin", vin).Str("device_definition_id", userDevice.DeviceDefinitionId).Str("user_device_id", userDeviceID).Logger()
existingOfferData, _ := models.Valuations(
models.ValuationWhere.Vin.EQ(vin),
models.ValuationWhere.OfferMetadata.IsNotNull(),
qm.OrderBy("updated_at desc"), qm.Limit(1)).
One(ctx, d.dbs().Writer)
if existingOfferData != nil {
if existingOfferData.CreatedAt.After(time.Now().Add(-time.Hour * 24 * 30)) {
return core.SkippedDataPullStatus, fmt.Errorf("instant offer already request in last 30 days")
}
}
// future: pull by tokenID from identity-api
deviceDef, err := d.ddSvc.GetDeviceDefinitionByID(ctx, userDevice.DeviceDefinitionId)
if err != nil {
return core.ErrorDataPullStatus, err
}
// get mileage for the drivly request
userDeviceData, err := d.uddSvc.GetVehicleRawData(ctx, userDeviceID)
if err != nil {
// just warn if can't get data
localLog.Warn().Err(err).Msgf("could not find any user device data to obtain mileage or location - continuing without")
}
deviceMileage := getDeviceMileage(userDeviceData, int(deviceDef.Type.Year), time.Now().Year())
if deviceMileage == 0 {
localLog.Warn().Msg("vehicle mileage found was 0")
}
params := ValuationRequestData{
Mileage: &deviceMileage,
ZipCode: &userDevice.PostalCode,
}
offer, err := d.drivlySvc.GetOffersByVIN(vin, ¶ms)
if err != nil {
localLog.Err(err).Msg("error pulling drivly offer data")
return core.ErrorDataPullStatus, err
}
// insert new offer record
newOffer := &models.Valuation{
ID: ksuid.New().String(),
DeviceDefinitionID: null.StringFrom(userDevice.DeviceDefinitionId),
Vin: vin,
UserDeviceID: null.StringFrom(userDeviceID),
TokenID: types.NewNullDecimal(decimal.New(int64(tokenID), 0)),
}
pj, err := json.Marshal(params)
if err == nil {
newOffer.OfferMetadata = null.JSONFrom(pj)
}
_ = newOffer.OfferMetadata.Marshal(offer)
err = newOffer.Insert(ctx, d.dbs().Writer, boil.Infer())
if err != nil {
return core.ErrorDataPullStatus, err
}
return core.PulledValuationDrivlyStatus, nil
}
const EstMilesPerYear = 12000.0
func getDeviceMileage(userDeviceData *pb.RawDeviceDataResponse, modelYear int, currentYear int) (mileage float64) {
if userDeviceData != nil {
// get the highest odometer found
odoKm := float64(0)
for _, item := range userDeviceData.Items {
odo := gjson.GetBytes(item.SignalsJsonData, "odometer.value").Float()
if odo > odoKm {
odoKm = odo
}
}
if odoKm > 0 {
return odoKm * 0.621271
}
}
// if get here means need to just estimate
deviceMileage := float64(0)
yearDiff := currentYear - modelYear
switch {
case yearDiff > 0:
// Past model year
deviceMileage = float64(yearDiff) * EstMilesPerYear
case yearDiff == 0:
// Current model year
deviceMileage = EstMilesPerYear / 2
default:
// Next model year
deviceMileage = 0
}
return deviceMileage
}
func (d *drivlyValuationService) setUserDeviceStyleFromEdmunds(ctx context.Context, edmunds map[string]interface{}, ud *pbdeviceapi.UserDevice) {
edmundsJSON, err := json.Marshal(edmunds)
if err != nil {
d.log.Err(err).Msg("could not marshal edmunds response to json")
return
}
styleIDResult := gjson.GetBytes(edmundsJSON, "edmundsStyle.data.style.id")
styleID := styleIDResult.String()
if styleIDResult.Exists() && len(styleID) > 0 {
deviceStyle, err := d.ddSvc.GetDeviceStyleByExternalID(ctx, styleID)
if err != nil {
d.log.Err(err).Msgf("unable to find device_style for edmunds style_id %s", styleID)
return
}
//TODO: edu
ud.DeviceStyleId = &deviceStyle.Id // set foreign key
//_, err = ud.Update(ctx, d.dbs().Writer, boil.Whitelist("updated_at", "device_style_id"))
//if err != nil {
// d.log.Err(err).Msgf("unable to update user_device_id %s with styleID %s", ud.Id, deviceStyle.Id)
// return
//}
}
}