404 lines
12 KiB
Go
404 lines
12 KiB
Go
package platform
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type Store interface {
|
|
DashboardSummary(context.Context) (DashboardSummary, error)
|
|
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
|
|
VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error)
|
|
VehicleRealtime(context.Context, url.Values) (Page[VehicleRealtimeRow], error)
|
|
RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], error)
|
|
HistoryLocations(context.Context, url.Values) (Page[HistoryLocationRow], error)
|
|
HistoryLocationsFromTDengine(context.Context, url.Values) (Page[HistoryLocationRow], error)
|
|
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
|
|
MileageSummary(context.Context, url.Values) (MileageSummary, error)
|
|
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
|
|
QualitySummary(context.Context, url.Values) (QualitySummary, error)
|
|
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
|
|
OpsHealth(context.Context) (OpsHealth, error)
|
|
}
|
|
|
|
type RawFrameQuery struct {
|
|
Protocol string `json:"protocol"`
|
|
VIN string `json:"vin"`
|
|
DateFrom string `json:"dateFrom"`
|
|
DateTo string `json:"dateTo"`
|
|
Fields []string `json:"fields"`
|
|
IncludeFields bool `json:"includeFields"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
type Service struct {
|
|
store Store
|
|
}
|
|
|
|
func NewService(store Store) *Service {
|
|
return &Service{store: store}
|
|
}
|
|
|
|
func (s *Service) DashboardSummary(ctx context.Context) (DashboardSummary, error) {
|
|
return s.store.DashboardSummary(ctx)
|
|
}
|
|
|
|
func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
|
return s.store.Vehicles(ctx, query)
|
|
}
|
|
|
|
func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
|
|
return s.store.VehicleCoverage(ctx, query)
|
|
}
|
|
|
|
func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
|
return s.store.VehicleRealtime(ctx, query)
|
|
}
|
|
|
|
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
|
keyword := strings.TrimSpace(vin)
|
|
protocol = strings.TrimSpace(protocol)
|
|
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
|
|
vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
|
|
if err != nil {
|
|
return VehicleDetail{}, err
|
|
}
|
|
identity := resolveVehicleIdentity(keyword, vehicles.Items)
|
|
resolvedVIN := ""
|
|
if identity != nil && strings.TrimSpace(identity.VIN) != "" {
|
|
resolvedVIN = identity.VIN
|
|
} else if isLikelyVIN(keyword) {
|
|
resolvedVIN = keyword
|
|
}
|
|
queryVIN := resolvedVIN
|
|
if queryVIN == "" {
|
|
queryVIN = keyword
|
|
}
|
|
if resolvedVIN == "" {
|
|
qualityQuery := url.Values{"keyword": {keyword}, "limit": {"20"}}
|
|
if protocol != "" {
|
|
qualityQuery.Set("protocol", protocol)
|
|
}
|
|
quality, err := s.store.QualityIssues(ctx, qualityQuery)
|
|
if err != nil {
|
|
return VehicleDetail{}, err
|
|
}
|
|
return VehicleDetail{
|
|
VIN: "",
|
|
LookupKey: keyword,
|
|
LookupResolved: false,
|
|
Sources: []string{},
|
|
SourceStatus: []VehicleSourceStatus{},
|
|
Realtime: []RealtimeLocationRow{},
|
|
History: Page[HistoryLocationRow]{Items: []HistoryLocationRow{}, Limit: 20, Offset: 0},
|
|
Raw: Page[RawFrameRow]{Items: []RawFrameRow{}, Limit: 10, Offset: 0},
|
|
Mileage: Page[DailyMileageRow]{Items: []DailyMileageRow{}, Limit: 20, Offset: 0},
|
|
Quality: quality,
|
|
}, nil
|
|
}
|
|
|
|
realtimeQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}}
|
|
historyQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}}
|
|
rawQuery := RawFrameQuery{VIN: queryVIN, IncludeFields: true, Limit: 10}
|
|
mileageQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}}
|
|
qualityQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}}
|
|
if protocol != "" {
|
|
realtimeQuery.Set("protocol", protocol)
|
|
historyQuery.Set("protocol", protocol)
|
|
rawQuery.Protocol = protocol
|
|
qualityQuery.Set("protocol", protocol)
|
|
}
|
|
realtimeSummary, err := s.store.VehicleRealtime(ctx, url.Values{"vin": {queryVIN}, "limit": {"1"}})
|
|
if err != nil {
|
|
return VehicleDetail{}, err
|
|
}
|
|
var summary *VehicleRealtimeRow
|
|
if len(realtimeSummary.Items) > 0 {
|
|
summary = &realtimeSummary.Items[0]
|
|
}
|
|
realtime, err := s.store.RealtimeLocations(ctx, realtimeQuery)
|
|
if err != nil {
|
|
return VehicleDetail{}, err
|
|
}
|
|
history, err := s.store.HistoryLocationsFromTDengine(ctx, historyQuery)
|
|
if err != nil {
|
|
return VehicleDetail{}, err
|
|
}
|
|
raw, err := s.RawFrames(ctx, rawQuery)
|
|
if err != nil {
|
|
return VehicleDetail{}, err
|
|
}
|
|
mileage, err := s.store.DailyMileage(ctx, mileageQuery)
|
|
if err != nil {
|
|
return VehicleDetail{}, err
|
|
}
|
|
quality, err := s.store.QualityIssues(ctx, qualityQuery)
|
|
if err != nil {
|
|
return VehicleDetail{}, err
|
|
}
|
|
sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items)
|
|
sourceStatus = s.enrichVehicleSourceStatus(ctx, queryVIN, sourceStatus)
|
|
return VehicleDetail{
|
|
VIN: resolvedVIN,
|
|
LookupKey: keyword,
|
|
LookupResolved: resolvedVIN != "",
|
|
Identity: identity,
|
|
RealtimeSummary: summary,
|
|
Sources: sourceNames(sourceStatus),
|
|
SourceStatus: sourceStatus,
|
|
Realtime: realtime.Items,
|
|
History: history,
|
|
Raw: raw,
|
|
Mileage: mileage,
|
|
Quality: quality,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
|
|
for index := range statuses {
|
|
protocol := statuses[index].Protocol
|
|
if protocol == "" {
|
|
continue
|
|
}
|
|
if !statuses[index].HasHistory {
|
|
query := url.Values{"vin": {vin}, "protocol": {protocol}, "limit": {"1"}}
|
|
if page, err := s.store.HistoryLocationsFromTDengine(ctx, query); err == nil && len(page.Items) > 0 {
|
|
statuses[index].HasHistory = true
|
|
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
|
|
}
|
|
}
|
|
if !statuses[index].HasRaw {
|
|
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, Limit: 1})
|
|
if err == nil && len(page.Items) > 0 {
|
|
statuses[index].HasRaw = true
|
|
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
|
|
}
|
|
}
|
|
}
|
|
return statuses
|
|
}
|
|
|
|
func resolveVehicleIdentity(keyword string, vehicles []VehicleRow) *VehicleRow {
|
|
keyword = strings.TrimSpace(keyword)
|
|
if keyword == "" {
|
|
return nil
|
|
}
|
|
for index := range vehicles {
|
|
if strings.EqualFold(vehicles[index].VIN, keyword) {
|
|
return &vehicles[index]
|
|
}
|
|
}
|
|
for index := range vehicles {
|
|
if strings.EqualFold(vehicles[index].Plate, keyword) || strings.EqualFold(vehicles[index].Phone, keyword) {
|
|
return &vehicles[index]
|
|
}
|
|
}
|
|
if len(vehicles) > 0 {
|
|
return &vehicles[0]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isLikelyVIN(value string) bool {
|
|
value = strings.ToUpper(strings.TrimSpace(value))
|
|
if len(value) != 17 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if char >= '0' && char <= '9' {
|
|
continue
|
|
}
|
|
if char >= 'A' && char <= 'Z' && char != 'I' && char != 'O' && char != 'Q' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Service) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
|
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
|
if err != nil {
|
|
return Page[RealtimeLocationRow]{}, err
|
|
}
|
|
return s.store.RealtimeLocations(ctx, resolvedQuery)
|
|
}
|
|
|
|
func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
|
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
|
if err != nil {
|
|
return Page[HistoryLocationRow]{}, err
|
|
}
|
|
return s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
|
}
|
|
|
|
func (s *Service) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
|
if query.Limit <= 0 || query.Limit > 500 {
|
|
query.Limit = 100
|
|
}
|
|
resolvedVIN, err := s.resolveVehicleVIN(ctx, query.VIN, query.Protocol)
|
|
if err != nil {
|
|
return Page[RawFrameRow]{}, err
|
|
}
|
|
if resolvedVIN != "" {
|
|
query.VIN = resolvedVIN
|
|
}
|
|
return s.store.RawFrames(ctx, query)
|
|
}
|
|
|
|
func (s *Service) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
|
|
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
|
if err != nil {
|
|
return MileageSummary{}, err
|
|
}
|
|
return s.store.MileageSummary(ctx, resolvedQuery)
|
|
}
|
|
|
|
func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
|
|
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
|
if err != nil {
|
|
return Page[DailyMileageRow]{}, err
|
|
}
|
|
return s.store.DailyMileage(ctx, resolvedQuery)
|
|
}
|
|
|
|
func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) {
|
|
return s.store.QualitySummary(ctx, query)
|
|
}
|
|
|
|
func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
|
return s.store.QualityIssues(ctx, query)
|
|
}
|
|
|
|
func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) {
|
|
return s.store.OpsHealth(ctx)
|
|
}
|
|
|
|
func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (url.Values, error) {
|
|
resolved := cloneValues(query)
|
|
vin := strings.TrimSpace(resolved.Get("vin"))
|
|
if vin == "" {
|
|
return resolved, nil
|
|
}
|
|
resolvedVIN, err := s.resolveVehicleVIN(ctx, vin, resolved.Get("protocol"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resolvedVIN != "" {
|
|
resolved.Set("vin", resolvedVIN)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protocol string) (string, error) {
|
|
keyword = strings.TrimSpace(keyword)
|
|
if keyword == "" || isLikelyVIN(keyword) {
|
|
return keyword, nil
|
|
}
|
|
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
|
|
if protocol = strings.TrimSpace(protocol); protocol != "" {
|
|
vehicleQuery.Set("protocol", protocol)
|
|
}
|
|
vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
identity := resolveVehicleIdentity(keyword, vehicles.Items)
|
|
if identity == nil || strings.TrimSpace(identity.VIN) == "" {
|
|
return keyword, nil
|
|
}
|
|
return identity.VIN, nil
|
|
}
|
|
|
|
func cloneValues(values url.Values) url.Values {
|
|
cloned := make(url.Values, len(values))
|
|
for key, current := range values {
|
|
cloned[key] = append([]string(nil), current...)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus {
|
|
statusByProtocol := map[string]*VehicleSourceStatus{}
|
|
ensure := func(protocol string) *VehicleSourceStatus {
|
|
protocol = strings.TrimSpace(protocol)
|
|
if protocol == "" {
|
|
return nil
|
|
}
|
|
if current := statusByProtocol[protocol]; current != nil {
|
|
return current
|
|
}
|
|
current := &VehicleSourceStatus{Protocol: protocol}
|
|
statusByProtocol[protocol] = current
|
|
return current
|
|
}
|
|
setLastSeen := func(current *VehicleSourceStatus, value string) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return
|
|
}
|
|
if current.LastSeen == "" || value > current.LastSeen {
|
|
current.LastSeen = value
|
|
}
|
|
}
|
|
for _, row := range vehicles {
|
|
if current := ensure(row.Protocol); current != nil {
|
|
current.Online = current.Online || row.Online
|
|
setLastSeen(current, row.LastSeen)
|
|
}
|
|
}
|
|
for _, row := range realtime {
|
|
if current := ensure(row.Protocol); current != nil {
|
|
current.HasRealtime = true
|
|
setLastSeen(current, row.LastSeen)
|
|
}
|
|
}
|
|
for _, row := range history {
|
|
if current := ensure(row.Protocol); current != nil {
|
|
current.HasHistory = true
|
|
setLastSeen(current, firstNonEmpty(row.ServerTime, row.DeviceTime))
|
|
}
|
|
}
|
|
for _, row := range raw {
|
|
if current := ensure(row.Protocol); current != nil {
|
|
current.HasRaw = true
|
|
setLastSeen(current, firstNonEmpty(row.ServerTime, row.DeviceTime))
|
|
}
|
|
}
|
|
for _, row := range mileage {
|
|
if current := ensure(row.Source); current != nil {
|
|
current.HasMileage = true
|
|
setLastSeen(current, row.Date)
|
|
}
|
|
}
|
|
out := make([]VehicleSourceStatus, 0, len(statusByProtocol))
|
|
for _, current := range statusByProtocol {
|
|
out = append(out, *current)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Protocol < out[j].Protocol })
|
|
return out
|
|
}
|
|
|
|
func latestString(left string, right string) string {
|
|
left = strings.TrimSpace(left)
|
|
right = strings.TrimSpace(right)
|
|
if left == "" {
|
|
return right
|
|
}
|
|
if right == "" || left >= right {
|
|
return left
|
|
}
|
|
return right
|
|
}
|
|
|
|
func sourceNames(statuses []VehicleSourceStatus) []string {
|
|
out := make([]string, 0, len(statuses))
|
|
for _, status := range statuses {
|
|
out = append(out, status.Protocol)
|
|
}
|
|
return out
|
|
}
|