Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
935 lines
50 KiB
Go
935 lines
50 KiB
Go
package platform
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type SQLQuery struct {
|
|
Text string
|
|
Args []any
|
|
CountText string
|
|
CountArgs []any
|
|
}
|
|
|
|
const maxSQLPageSize = 10_000
|
|
|
|
func buildVehicleListSQL(query url.Values) SQLQuery {
|
|
limit := parseSQLPageSize(query.Get("limit"), 20)
|
|
offset := parsePositive(query.Get("offset"), 0)
|
|
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
|
|
args := []any{}
|
|
where := []string{"1 = 1"}
|
|
where, args = appendVINListFilter(where, args, "s.vin", query.Get("scopeVins"))
|
|
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
|
where = append(where, "(b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
|
like := "%" + keyword + "%"
|
|
args = append(args, like, like, like, like)
|
|
}
|
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
where = append(where, "s.protocol = ?")
|
|
args = append(args, protocol)
|
|
}
|
|
switch strings.TrimSpace(query.Get("serviceStatus")) {
|
|
case "identity_required":
|
|
where = append(where, "(b.vin IS NULL OR b.vin = '')")
|
|
case "offline":
|
|
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''", "COALESCE(vs.source_count, 0) > 0", "COALESCE(vs.online_source_count, 0) = 0")
|
|
case "degraded":
|
|
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''", "vs.source_count > 0", "vs.online_source_count > 0", "(vs.online_source_count < vs.source_count OR vs.source_count < "+canonicalSourceCount+")")
|
|
case "healthy":
|
|
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''", "vs.source_count = "+canonicalSourceCount, "vs.online_source_count = vs.source_count")
|
|
}
|
|
countArgs := append([]any(nil), args...)
|
|
args = append(args, limit, offset)
|
|
fromSQL := `FROM vehicle_realtime_snapshot s ` +
|
|
`LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
|
|
`LEFT JOIN vehicle_realtime_location l ON l.vin = s.vin AND l.protocol = s.protocol ` +
|
|
`LEFT JOIN (` +
|
|
`SELECT vin, COUNT(DISTINCT protocol) AS source_count, ` +
|
|
`COUNT(DISTINCT CASE WHEN updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN protocol END) AS online_source_count ` +
|
|
`FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> '' GROUP BY vin` +
|
|
`) vs ON vs.vin = s.vin ` +
|
|
`WHERE ` + strings.Join(where, " AND ")
|
|
return SQLQuery{
|
|
Text: `SELECT s.vin, COALESCE(NULLIF(s.plate, ''), b.plate, '') AS plate, COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, s.protocol, ` +
|
|
`CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END AS online, ` +
|
|
`COALESCE(DATE_FORMAT(s.updated_at, '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
|
`COALESCE(CONCAT(l.longitude, ',', l.latitude), '') AS location_text, ` +
|
|
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 100 ELSE 60 END AS binding_score, ` +
|
|
`COALESCE(vs.source_count, 0) AS source_count, COALESCE(vs.online_source_count, 0) AS online_source_count, ` +
|
|
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` +
|
|
fromSQL + ` ORDER BY s.updated_at DESC, s.vin ASC, s.protocol ASC LIMIT ? OFFSET ?`,
|
|
Args: args,
|
|
CountText: `SELECT COUNT(*) ` + fromSQL,
|
|
CountArgs: countArgs,
|
|
}
|
|
}
|
|
|
|
func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
|
limit := parseSQLPageSize(query.Get("limit"), 20)
|
|
offset := parsePositive(query.Get("offset"), 0)
|
|
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
|
|
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
|
|
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
|
having := []string{}
|
|
where, args = appendBusinessCoverageFilters(where, args, query)
|
|
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
|
|
if strings.TrimSpace(query.Get("keywords")) != "" {
|
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(keywords)), ",")
|
|
where = append(where, "(v.vin IN ("+placeholders+") OR s.plate IN ("+placeholders+") OR b.vin IN ("+placeholders+") OR b.plate IN ("+placeholders+") OR b.phone IN ("+placeholders+"))")
|
|
for range 5 {
|
|
for _, keyword := range keywords {
|
|
args = append(args, keyword)
|
|
}
|
|
}
|
|
} else {
|
|
keyword := keywords[0]
|
|
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
|
like := "%" + keyword + "%"
|
|
args = append(args, like, like, like, like, like, like)
|
|
}
|
|
}
|
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
where = append(where, "s.protocol = ?")
|
|
args = append(args, protocol)
|
|
}
|
|
switch strings.TrimSpace(query.Get("coverage")) {
|
|
case "single":
|
|
having = append(having, "COUNT(DISTINCT s.protocol) = 1")
|
|
case "multi":
|
|
having = append(having, "COUNT(DISTINCT s.protocol) > 1")
|
|
}
|
|
if missingProtocol := strings.TrimSpace(query.Get("missingProtocol")); missingProtocol != "" {
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.protocol = ? THEN s.protocol END) = 0")
|
|
args = append(args, missingProtocol)
|
|
}
|
|
switch strings.TrimSpace(query.Get("online")) {
|
|
case "online":
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
|
|
case "offline":
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
|
|
}
|
|
switch strings.TrimSpace(query.Get("bindingStatus")) {
|
|
case "bound":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
case "unbound":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
|
}
|
|
switch strings.TrimSpace(query.Get("archiveStatus")) {
|
|
case "complete":
|
|
having = append(having, "v.vin IS NOT NULL AND v.vin <> ''")
|
|
having = append(having, "COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL")
|
|
having = append(having, "NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL")
|
|
having = append(having, "NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL")
|
|
case "incomplete":
|
|
having = append(having, "(v.vin IS NULL OR v.vin = '' OR COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL OR NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL OR NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL)")
|
|
}
|
|
if predicate := archiveMissingFieldPredicate(query.Get("archiveMissing")); predicate != "" {
|
|
having = append(having, predicate)
|
|
}
|
|
switch strings.TrimSpace(query.Get("serviceStatus")) {
|
|
case "identity_required":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
|
case "no_data":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
having = append(having, "COUNT(DISTINCT s.protocol) = 0")
|
|
case "offline":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
|
|
case "degraded":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
|
|
having = append(having, "(COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) < COUNT(DISTINCT s.protocol) OR COUNT(DISTINCT s.protocol) < "+canonicalSourceCount+")")
|
|
case "healthy":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
having = append(having, "COUNT(DISTINCT s.protocol) = "+canonicalSourceCount)
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = COUNT(DISTINCT s.protocol)")
|
|
}
|
|
countArgs := append([]any(nil), args...)
|
|
args = append(args, limit, offset)
|
|
havingSQL := ""
|
|
if len(having) > 0 {
|
|
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
|
}
|
|
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
|
|
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
|
`LEFT JOIN vehicle_profile p ON p.vin = v.vin ` +
|
|
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
|
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
|
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
|
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
|
`GROUP BY v.vin, b.plate, b.phone, b.oem, b.vin ` +
|
|
havingSQL
|
|
return SQLQuery{
|
|
Text: `SELECT v.vin, ` +
|
|
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
|
|
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
|
|
`COALESCE(NULLIF(MAX(NULLIF(p.brand_name, '')), ''), NULLIF(b.oem, ''), '') AS brand_name, ` +
|
|
`COALESCE(MAX(p.model_name), '') AS model_name, ` +
|
|
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
|
|
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END ORDER BY s.protocol SEPARATOR ','), '') AS online_protocols, ` +
|
|
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
|
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
|
|
`CASE WHEN COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
|
|
`COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
|
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` +
|
|
groupSQL +
|
|
`ORDER BY MAX(s.updated_at) DESC, v.vin ASC LIMIT ? OFFSET ?`,
|
|
Args: args,
|
|
CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + groupSQL + `) vehicle_coverage_count`,
|
|
CountArgs: countArgs,
|
|
}
|
|
}
|
|
|
|
func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
|
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
|
|
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
|
|
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
|
having := []string{}
|
|
where, args = appendBusinessCoverageFilters(where, args, query)
|
|
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
|
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
|
like := "%" + keyword + "%"
|
|
args = append(args, like, like, like, like, like, like)
|
|
}
|
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
where = append(where, "s.protocol = ?")
|
|
args = append(args, protocol)
|
|
}
|
|
switch strings.TrimSpace(query.Get("coverage")) {
|
|
case "single":
|
|
having = append(having, "COUNT(DISTINCT s.protocol) = 1")
|
|
case "multi":
|
|
having = append(having, "COUNT(DISTINCT s.protocol) > 1")
|
|
}
|
|
if missingProtocol := strings.TrimSpace(query.Get("missingProtocol")); missingProtocol != "" {
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.protocol = ? THEN s.protocol END) = 0")
|
|
args = append(args, missingProtocol)
|
|
}
|
|
switch strings.TrimSpace(query.Get("online")) {
|
|
case "online":
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
|
|
case "offline":
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
|
|
}
|
|
switch strings.TrimSpace(query.Get("bindingStatus")) {
|
|
case "bound":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
case "unbound":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
|
}
|
|
switch strings.TrimSpace(query.Get("archiveStatus")) {
|
|
case "complete":
|
|
having = append(having, "v.vin IS NOT NULL AND v.vin <> ''")
|
|
having = append(having, "COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL")
|
|
having = append(having, "NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL")
|
|
having = append(having, "NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL")
|
|
case "incomplete":
|
|
having = append(having, "(v.vin IS NULL OR v.vin = '' OR COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL OR NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL OR NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL)")
|
|
}
|
|
if predicate := archiveMissingFieldPredicate(query.Get("archiveMissing")); predicate != "" {
|
|
having = append(having, predicate)
|
|
}
|
|
switch strings.TrimSpace(query.Get("serviceStatus")) {
|
|
case "identity_required":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
|
case "no_data":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
having = append(having, "COUNT(DISTINCT s.protocol) = 0")
|
|
case "offline":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
|
|
case "degraded":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
|
|
having = append(having, "(COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) < COUNT(DISTINCT s.protocol) OR COUNT(DISTINCT s.protocol) < "+canonicalSourceCount+")")
|
|
case "healthy":
|
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
|
having = append(having, "COUNT(DISTINCT s.protocol) = "+canonicalSourceCount)
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = COUNT(DISTINCT s.protocol)")
|
|
}
|
|
havingSQL := ""
|
|
if len(having) > 0 {
|
|
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
|
}
|
|
groupSQL := `SELECT v.vin, ` +
|
|
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
|
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
|
|
`MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) AS bound, ` +
|
|
`CASE WHEN v.vin IS NOT NULL AND v.vin <> '' ` +
|
|
`AND COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL ` +
|
|
`AND NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL ` +
|
|
`AND NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL ` +
|
|
`THEN 1 ELSE 0 END AS archive_complete, ` +
|
|
`CASE WHEN COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL THEN 1 ELSE 0 END AS missing_plate, ` +
|
|
`CASE WHEN NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_phone, ` +
|
|
`CASE WHEN NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_oem ` +
|
|
`FROM (` + vehicleSetSQL + `) v ` +
|
|
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
|
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
|
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
|
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
|
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
|
`GROUP BY v.vin ` + havingSQL
|
|
return SQLQuery{
|
|
Text: `SELECT COUNT(*) AS total_vehicles, ` +
|
|
`COALESCE(SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END), 0) AS online_vehicles, ` +
|
|
`COALESCE(SUM(CASE WHEN source_count = 1 THEN 1 ELSE 0 END), 0) AS single_source_vehicles, ` +
|
|
`COALESCE(SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END), 0) AS multi_source_vehicles, ` +
|
|
`COALESCE(SUM(CASE WHEN source_count = 0 THEN 1 ELSE 0 END), 0) AS no_data_vehicles, ` +
|
|
`COALESCE(SUM(CASE WHEN bound = 0 THEN 1 ELSE 0 END), 0) AS unbound_vehicles, ` +
|
|
`COALESCE(SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END), 0) AS archive_incomplete_vehicles, ` +
|
|
`COALESCE(SUM(CASE WHEN missing_plate = 1 THEN 1 ELSE 0 END), 0) AS missing_plate_vehicles, ` +
|
|
`COALESCE(SUM(CASE WHEN missing_phone = 1 THEN 1 ELSE 0 END), 0) AS missing_phone_vehicles, ` +
|
|
`COALESCE(SUM(CASE WHEN missing_oem = 1 THEN 1 ELSE 0 END), 0) AS missing_oem_vehicles ` +
|
|
`FROM (` + groupSQL + `) vehicle_coverage_summary`,
|
|
Args: args,
|
|
}
|
|
}
|
|
|
|
func appendBusinessCoverageFilters(where []string, args []any, query url.Values) ([]string, []any) {
|
|
where, args = appendCSVListFilter(where, args, "bs.department_id", query.Get("departmentIds"))
|
|
where, args = appendCSVListFilter(where, args, "bs.responsible_user_id", query.Get("responsibleUserIds"))
|
|
where, args = appendCSVListFilter(where, args, "CAST(bs.customer_id AS CHAR)", query.Get("customerIds"))
|
|
where, args = appendCSVListFilter(where, args, "bs.operation_status", query.Get("operationStatuses"))
|
|
return where, args
|
|
}
|
|
|
|
func buildVehicleCoverageSetSQL(scope string) (string, []any) {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope == "" {
|
|
return `SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''`, nil
|
|
}
|
|
vins := splitCSV(scope)
|
|
if len(vins) == 0 || (len(vins) == 1 && vins[0] == "__NO_VEHICLE_SCOPE__") {
|
|
return `SELECT CAST(NULL AS CHAR(64)) AS vin WHERE 1 = 0`, nil
|
|
}
|
|
selects := make([]string, len(vins))
|
|
args := make([]any, len(vins))
|
|
for index, vin := range vins {
|
|
if index == 0 {
|
|
selects[index] = `SELECT ? AS vin`
|
|
} else {
|
|
selects[index] = `SELECT ?`
|
|
}
|
|
args[index] = vin
|
|
}
|
|
return `SELECT DISTINCT scoped.vin FROM (` + strings.Join(selects, ` UNION ALL `) + `) scoped`, args
|
|
}
|
|
|
|
func archiveMissingFieldPredicate(field string) string {
|
|
switch strings.TrimSpace(field) {
|
|
case "plate":
|
|
return "COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL"
|
|
case "phone":
|
|
return "NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL"
|
|
case "oem":
|
|
return "NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func buildRealtimeLocationSQL(query url.Values) SQLQuery {
|
|
limit := parseSQLPageSize(query.Get("limit"), 20)
|
|
offset := parsePositive(query.Get("offset"), 0)
|
|
args := []any{}
|
|
where := []string{"1 = 1"}
|
|
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
|
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
where = append(where, "l.protocol = ?")
|
|
args = append(args, protocol)
|
|
}
|
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
|
where = append(where, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ?)")
|
|
like := "%" + vin + "%"
|
|
args = append(args, like, like, like)
|
|
}
|
|
countArgs := append([]any(nil), args...)
|
|
args = append(args, limit, offset)
|
|
fromSQL := `FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin WHERE ` + strings.Join(where, " AND ")
|
|
return SQLQuery{
|
|
Text: `SELECT l.vin, COALESCE(NULLIF(l.plate, ''), b.plate, '') AS plate, l.protocol, l.longitude, l.latitude, ` +
|
|
`COALESCE(l.speed_kmh, 0), COALESCE(l.soc_percent, 0), COALESCE(l.total_mileage_km, 0), ` +
|
|
`COALESCE(DATE_FORMAT(l.updated_at, '%Y-%m-%d %H:%i:%s'), '') ` +
|
|
fromSQL + ` ORDER BY l.updated_at DESC, l.vin ASC, l.protocol ASC LIMIT ? OFFSET ?`,
|
|
Args: args,
|
|
CountText: `SELECT COUNT(*) ` + fromSQL,
|
|
CountArgs: countArgs,
|
|
}
|
|
}
|
|
|
|
func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
|
limit := parseSQLPageSize(query.Get("limit"), 20)
|
|
offset := parsePositive(query.Get("offset"), 0)
|
|
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
|
|
args := []any{}
|
|
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
|
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
|
|
having := []string{}
|
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
where = append(where, "l.protocol = ?")
|
|
args = append(args, protocol)
|
|
}
|
|
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
|
|
if strings.TrimSpace(query.Get("keywords")) != "" {
|
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(keywords)), ",")
|
|
where = append(where, "(v.vin IN ("+placeholders+") OR l.plate IN ("+placeholders+") OR v.plate IN ("+placeholders+"))")
|
|
for range 3 {
|
|
for _, keyword := range keywords {
|
|
args = append(args, keyword)
|
|
}
|
|
}
|
|
} else {
|
|
keyword := keywords[0]
|
|
like := "%" + keyword + "%"
|
|
where = append(where, "(v.vin LIKE ? OR l.plate LIKE ? OR v.plate LIKE ? OR v.phone LIKE ? OR v.oem LIKE ?)")
|
|
args = append(args, like, like, like, like, like)
|
|
}
|
|
// Extend only the identity predicate; the authorized VIN scope remains ANDed.
|
|
if vins := vehicleNumberVINs(keywords, strings.TrimSpace(query.Get("keywords")) != ""); len(vins) > 0 {
|
|
last := len(where) - 1
|
|
where[last] = "(" + where[last] + " OR v.vin IN (" + strings.TrimSuffix(strings.Repeat("?,", len(vins)), ",") + "))"
|
|
for _, vin := range vins {
|
|
args = append(args, vin)
|
|
}
|
|
}
|
|
}
|
|
switch strings.TrimSpace(query.Get("online")) {
|
|
case "online":
|
|
having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0")
|
|
case "offline":
|
|
having = append(having,
|
|
"COUNT(DISTINCT l.protocol) > 0",
|
|
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0",
|
|
)
|
|
}
|
|
orderExpr := `CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) THEN 0 ELSE 1 END ASC, ` +
|
|
`CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) THEN CASE l.protocol ` +
|
|
`WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END ELSE 100 END ASC, ` +
|
|
`l.updated_at DESC, l.protocol ASC`
|
|
primarySpeed := "CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY " + orderExpr + "), ',', 1) AS DECIMAL(18,6))"
|
|
primaryLocationAvailable := `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(` +
|
|
`CASE WHEN l.vin IS NOT NULL AND l.longitude BETWEEN -180 AND 180 AND l.latitude BETWEEN -90 AND 90 ` +
|
|
`AND NOT (l.longitude = 0 AND l.latitude = 0) THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0)`
|
|
switch strings.TrimSpace(query.Get("status")) {
|
|
case "driving":
|
|
having = append(having,
|
|
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
|
|
primarySpeed+" > 3",
|
|
)
|
|
case "idle":
|
|
having = append(having,
|
|
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
|
|
primarySpeed+" <= 3",
|
|
)
|
|
case "no_location":
|
|
having = append(having, primaryLocationAvailable+" = 0")
|
|
}
|
|
switch strings.TrimSpace(query.Get("serviceStatus")) {
|
|
case "healthy":
|
|
having = append(having,
|
|
"MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 1",
|
|
"COUNT(DISTINCT l.protocol) = "+canonicalSourceCount,
|
|
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = COUNT(DISTINCT l.protocol)",
|
|
)
|
|
case "degraded":
|
|
having = append(having,
|
|
"MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 1",
|
|
"COUNT(DISTINCT l.protocol) > 0",
|
|
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
|
|
"(COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) < COUNT(DISTINCT l.protocol) OR COUNT(DISTINCT l.protocol) < "+canonicalSourceCount+")",
|
|
)
|
|
case "offline":
|
|
having = append(having,
|
|
"MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 1",
|
|
"COUNT(DISTINCT l.protocol) > 0",
|
|
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0",
|
|
)
|
|
case "identity_required":
|
|
having = append(having, "MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 0")
|
|
}
|
|
countArgs := append([]any(nil), args...)
|
|
args = append(args, limit, offset)
|
|
havingSQL := ""
|
|
if len(having) > 0 {
|
|
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
|
}
|
|
populationRowsSQL := `SELECT b0.vin, COALESCE(MAX(NULLIF(b0.plate, '')), '') AS plate, ` +
|
|
`COALESCE(MAX(NULLIF(b0.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(b0.oem, '')), '') AS oem, 'bound' AS binding_status ` +
|
|
`FROM vehicle_identity_binding b0 WHERE b0.vin IS NOT NULL AND b0.vin <> '' GROUP BY b0.vin`
|
|
if strings.TrimSpace(query.Get("serviceStatus")) == "identity_required" {
|
|
populationRowsSQL += ` UNION ALL ` +
|
|
`SELECT l0.vin, COALESCE(MAX(NULLIF(l0.plate, '')), '') AS plate, '' AS phone, '' AS oem, 'unbound' AS binding_status ` +
|
|
`FROM vehicle_realtime_location l0 LEFT JOIN vehicle_identity_binding b1 ON b1.vin = l0.vin ` +
|
|
`WHERE l0.vin IS NOT NULL AND l0.vin <> '' AND b1.vin IS NULL GROUP BY l0.vin`
|
|
}
|
|
populationSQL := `(` + populationRowsSQL + `) v`
|
|
baseGroupSQL := `FROM ` + populationSQL + ` ` +
|
|
`LEFT JOIN vehicle_realtime_location l ON l.vin = v.vin ` +
|
|
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin AND s.protocol = l.protocol `
|
|
groupSuffixSQL := `WHERE ` + strings.Join(where, " AND ") + ` ` +
|
|
`GROUP BY v.vin, v.plate, v.phone, v.oem, v.binding_status ` +
|
|
havingSQL
|
|
groupSQL := baseGroupSQL +
|
|
`LEFT JOIN (` +
|
|
`SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km FROM vehicle_daily_mileage ` +
|
|
`WHERE stat_date = CURDATE() GROUP BY vin, protocol` +
|
|
`) m ON m.vin = v.vin AND m.protocol = l.protocol ` +
|
|
groupSuffixSQL
|
|
todayMileageOrderExpr := `CASE WHEN COALESCE(m.daily_mileage_km, 0) > 0 THEN 0 ELSE 1 END, ` + orderExpr
|
|
// Keep one authoritative coordinate source while all protocols are healthy.
|
|
// Ordering only by updated_at makes the selected point flap whenever protocols
|
|
// report at different cadences. When every source is stale, recency remains the
|
|
// safest fallback so an old high-priority source cannot mask newer evidence.
|
|
vehicleOrderSQL := `MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC`
|
|
if strings.EqualFold(strings.TrimSpace(query.Get("sort")), "identity") {
|
|
vehicleOrderSQL = `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') ASC, v.vin ASC`
|
|
}
|
|
selectSQL := `SELECT v.vin, ` +
|
|
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` +
|
|
`COALESCE(v.phone, '') AS phone, COALESCE(v.oem, '') AS oem, ` +
|
|
`COALESCE(GROUP_CONCAT(DISTINCT l.protocol ORDER BY l.protocol SEPARATOR ','), '') AS protocols, ` +
|
|
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END ORDER BY l.protocol SEPARATOR ','), '') AS online_protocols, ` +
|
|
`COUNT(DISTINCT l.protocol) AS source_count, ` +
|
|
`COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` +
|
|
`CASE WHEN COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
|
|
`v.binding_status, ` +
|
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` +
|
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')) ORDER BY ` + orderExpr + `), ',', 1), '') AS location_source, ` +
|
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(l.location_conflict, 0) ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS location_conflict, ` +
|
|
`SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(CAST(l.location_conflict_distance_m AS CHAR), '-1') ORDER BY ` + orderExpr + `), ',', 1) AS conflict_distance_m, ` +
|
|
primaryLocationAvailable + ` AS location_available, ` +
|
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` +
|
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` +
|
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.speed_kmh IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS speed_available, ` +
|
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` +
|
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.protocol IN ('GB32960','YUTONG_MQTT') AND l.soc_percent IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS soc_available, ` +
|
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` +
|
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.total_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS mileage_available, ` +
|
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` +
|
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN m.daily_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + todayMileageOrderExpr + `), ',', 1) AS UNSIGNED), 0) AS today_mileage_available, ` +
|
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY ` + todayMileageOrderExpr + `), ',', 1), '') AS today_mileage_km, ` +
|
|
`COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
|
`CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` +
|
|
groupSQL
|
|
text := selectSQL + `ORDER BY ` + vehicleOrderSQL + ` LIMIT ? OFFSET ?`
|
|
if sorted, ok := monitorEnergySortSQL(selectSQL, query); ok {
|
|
text = sorted
|
|
}
|
|
return SQLQuery{
|
|
Text: text,
|
|
Args: args,
|
|
CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + baseGroupSQL + groupSuffixSQL + `) vehicle_realtime_count`,
|
|
CountArgs: countArgs,
|
|
}
|
|
}
|
|
|
|
func buildDailyMileageSQL(query url.Values) SQLQuery {
|
|
limit := parseSQLPageSize(query.Get("limit"), 20)
|
|
offset := parsePositive(query.Get("offset"), 0)
|
|
args := []any{}
|
|
where := []string{"1 = 1"}
|
|
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
|
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
|
}
|
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
|
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
|
like := "%" + vin + "%"
|
|
args = append(args, like, like, like, like)
|
|
}
|
|
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
|
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
|
|
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", query.Get("scopeVehicleFrom"))
|
|
protocols := parseMileageProtocols(query.Get("protocols"))
|
|
if len(protocols) > 0 {
|
|
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
|
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
where = append(where, "m.protocol = ?")
|
|
args = append(args, protocol)
|
|
}
|
|
if dateFrom := strings.TrimSpace(query.Get("dateFrom")); dateFrom != "" {
|
|
where = append(where, "m.stat_date >= ?")
|
|
args = append(args, dateFrom)
|
|
}
|
|
if dateTo := strings.TrimSpace(query.Get("dateTo")); dateTo != "" {
|
|
where = append(where, "m.stat_date <= ?")
|
|
args = append(args, dateTo)
|
|
}
|
|
countArgs := append([]any(nil), args...)
|
|
args = append(args, limit, offset)
|
|
// Both joins are one-to-one. Binding is needed during filtering only for
|
|
// keyword/bound scope; energy never affects which mileage rows qualify.
|
|
filterFromSQL := `FROM vehicle_daily_mileage m`
|
|
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") || strings.TrimSpace(query.Get("vin")) != "" {
|
|
filterFromSQL += ` LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin`
|
|
}
|
|
filterFromSQL += ` WHERE ` + strings.Join(where, " AND ")
|
|
enrichmentSQL := ` LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
|
LEFT JOIN vehicle_open_daily_energy h
|
|
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
|
AND h.stat_date = m.stat_date
|
|
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')
|
|
LEFT JOIN vehicle_daily_geography g ON g.vin = m.vin COLLATE utf8mb4_unicode_ci AND g.stat_date = m.stat_date`
|
|
|
|
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
|
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
|
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
|
pureHydrogenMileageExpression := `MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))`
|
|
if len(protocols) > 0 {
|
|
selectionOrder = mileageDailySelectionOrder("m.daily_mileage_km", "m.protocol", protocols)
|
|
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
|
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
|
}
|
|
groupSQL := filterFromSQL + ` GROUP BY m.vin, m.stat_date`
|
|
// LIMIT materializes the grouped page before enrichment. Keep the exact
|
|
// original aggregate/priority rules, including independent maxima when no
|
|
// enabled protocol priority was supplied.
|
|
pageSQL := `SELECT m.vin, m.stat_date, ` +
|
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km - m.daily_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS start_mileage_km, ` +
|
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS end_mileage_km, ` +
|
|
dailyMileageExpression + ` AS daily_mileage_km, ` + pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
|
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
|
|
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`
|
|
built := SQLQuery{
|
|
Text: `SELECT m.vin, COALESCE(NULLIF(b.plate, ''), '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
|
`m.start_mileage_km, m.end_mileage_km, m.daily_mileage_km, m.pure_hydrogen_mileage_km, ` +
|
|
`h.consumption_kg AS hydrogen_consumption_kg, h.consumption_kg_per_100km AS hydrogen_consumption_kg_per_100km, ` +
|
|
`h.soc_balanced_consumption_kg AS hydrogen_soc_balanced_kg, h.soc_balanced_kg_per_100km AS hydrogen_soc_balanced_kg_per_100km, ` +
|
|
`h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, ` +
|
|
`CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END AS hydrogen_evidence_available, ` +
|
|
`COALESCE(h.quality_status, '') AS hydrogen_quality_status, COALESCE(h.quality_reason, '') AS hydrogen_quality_reason, ` +
|
|
`COALESCE(h.algorithm_version, '') AS hydrogen_algorithm_version, m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
|
|
`FROM (` + pageSQL + `) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC`,
|
|
Args: args,
|
|
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
|
|
CountArgs: countArgs,
|
|
}
|
|
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
|
built.CountText = ""
|
|
built.CountArgs = nil
|
|
}
|
|
return built
|
|
}
|
|
built := SQLQuery{
|
|
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
|
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
|
|
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
|
|
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` +
|
|
`h.soc_balanced_kg_per_100km, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` +
|
|
`h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
|
|
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
|
|
`FROM (SELECT m.* ` + filterFromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC`,
|
|
Args: args,
|
|
CountText: `SELECT COUNT(*) ` + filterFromSQL,
|
|
CountArgs: countArgs,
|
|
}
|
|
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
|
built.CountText = ""
|
|
built.CountArgs = nil
|
|
}
|
|
return built
|
|
}
|
|
|
|
func buildMileageSummarySQL(query url.Values) SQLQuery {
|
|
args := []any{}
|
|
where := []string{"1 = 1"}
|
|
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
|
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
|
}
|
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
|
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
|
like := "%" + vin + "%"
|
|
args = append(args, like, like, like, like)
|
|
}
|
|
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
|
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
|
|
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", query.Get("scopeVehicleFrom"))
|
|
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
|
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
|
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
where = append(where, "m.protocol = ?")
|
|
args = append(args, protocol)
|
|
}
|
|
if dateFrom := strings.TrimSpace(query.Get("dateFrom")); dateFrom != "" {
|
|
where = append(where, "m.stat_date >= ?")
|
|
args = append(args, dateFrom)
|
|
}
|
|
if dateTo := strings.TrimSpace(query.Get("dateTo")); dateTo != "" {
|
|
where = append(where, "m.stat_date <= ?")
|
|
args = append(args, dateTo)
|
|
}
|
|
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
|
|
return SQLQuery{
|
|
Text: `SELECT COUNT(DISTINCT m.vin) AS vehicle_count, COUNT(*) AS record_count, ` +
|
|
`COUNT(DISTINCT m.protocol) AS source_count, COALESCE(SUM(m.daily_mileage_km), 0) AS total_mileage_km, ` +
|
|
`COALESCE(SUM(m.pure_hydrogen_mileage_km), 0) AS total_pure_hydrogen_mileage_km ` + fromSQL,
|
|
Args: args,
|
|
}
|
|
}
|
|
|
|
func buildMileageStatisticsWhere(query url.Values) (string, []any) {
|
|
where := []string{"1 = 1"}
|
|
args := []any{}
|
|
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
|
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
|
}
|
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
|
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
|
like := "%" + vin + "%"
|
|
args = append(args, like, like, like, like)
|
|
}
|
|
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
|
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
|
|
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", query.Get("scopeVehicleFrom"))
|
|
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
|
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
|
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
where = append(where, "m.protocol = ?")
|
|
args = append(args, protocol)
|
|
}
|
|
if dateFrom := strings.TrimSpace(query.Get("dateFrom")); dateFrom != "" {
|
|
where = append(where, "m.stat_date >= ?")
|
|
args = append(args, dateFrom)
|
|
}
|
|
if dateTo := strings.TrimSpace(query.Get("dateTo")); dateTo != "" {
|
|
where = append(where, "m.stat_date <= ?")
|
|
args = append(args, dateTo)
|
|
}
|
|
return strings.Join(where, " AND "), args
|
|
}
|
|
|
|
func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
|
where, args := buildMileageStatisticsWhere(query)
|
|
protocols := parseMileageProtocols(query.Get("protocols"))
|
|
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
|
pureHydrogenMileageExpression := `MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))`
|
|
latestMileageExpression := `MAX(COALESCE(m.latest_total_mileage_km, 0))`
|
|
if len(protocols) > 0 {
|
|
selectionOrder := mileageDailySelectionOrder("m.daily_mileage_km", "m.protocol", protocols)
|
|
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
|
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
|
latestMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.latest_total_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
|
}
|
|
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
|
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
|
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
|
`CASE WHEN MAX(COALESCE(h.mixed_mileage_km,0))>0 THEN MAX(h.mixed_mileage_km) ELSE ` + pureHydrogenMileageExpression + ` END AS hydrogen_matched_mileage_km, ` +
|
|
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_consumption_kg, ` +
|
|
latestMileageExpression + ` AS latest_mileage_km ` +
|
|
`FROM vehicle_daily_mileage m
|
|
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
|
LEFT JOIN vehicle_open_daily_energy h
|
|
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
|
AND h.stat_date = m.stat_date
|
|
AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK' ` +
|
|
`WHERE ` + where + ` GROUP BY m.vin, m.stat_date`, args
|
|
}
|
|
|
|
func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
|
|
base, args := buildMileageStatisticsBaseSQL(query)
|
|
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
|
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
|
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
|
|
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
|
|
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
|
|
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
|
|
}
|
|
|
|
func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
|
|
base, args := buildMileageStatisticsBaseSQL(query)
|
|
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
|
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
|
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
|
|
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
|
|
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
|
|
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
|
|
}
|
|
|
|
func buildMileageStatisticsRankingSQL(query url.Values) SQLQuery {
|
|
base, args := buildMileageStatisticsBaseSQL(query)
|
|
return SQLQuery{Text: `SELECT d.vin, COALESCE(MAX(d.plate), ''), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(d.latest_mileage_km AS CHAR) ORDER BY d.stat_date DESC), ',', 1) AS DECIMAL(18,3)), 0), ` +
|
|
`COUNT(DISTINCT d.stat_date) FROM (` + base + `) d ` +
|
|
`GROUP BY d.vin ORDER BY SUM(d.daily_mileage_km) DESC, d.vin ASC LIMIT 20`, Args: args}
|
|
}
|
|
|
|
func buildMileageStatisticsSourceCountSQL(query url.Values) SQLQuery {
|
|
where, args := buildMileageStatisticsWhere(query)
|
|
return SQLQuery{Text: `SELECT COUNT(DISTINCT m.protocol) FROM vehicle_daily_mileage m ` +
|
|
`LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + where, Args: args}
|
|
}
|
|
|
|
func buildFleetLatestMileageSQL(query url.Values) SQLQuery {
|
|
where := []string{"l.total_mileage_km IS NOT NULL", "l.total_mileage_km >= 0"}
|
|
args := []any{}
|
|
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
|
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
|
}
|
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
|
where = append(where, "(l.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
|
like := "%" + vin + "%"
|
|
args = append(args, like, like, like, like)
|
|
}
|
|
where, args = appendVINListFilter(where, args, "l.vin", query.Get("vins"))
|
|
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
|
|
protocols := parseMileageProtocols(query.Get("protocols"))
|
|
if len(protocols) > 0 {
|
|
where, args = appendMileageProtocolFilter(where, args, "l.protocol", protocols)
|
|
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
where = append(where, "l.protocol = ?")
|
|
args = append(args, protocol)
|
|
}
|
|
selectionOrder := `l.updated_at DESC, l.protocol ASC`
|
|
if len(protocols) > 0 {
|
|
selectionOrder = mileageProtocolOrder("l.protocol", protocols) + `, l.updated_at DESC`
|
|
}
|
|
inner := `SELECT l.vin, CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ` +
|
|
`ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)) AS latest_mileage_km ` +
|
|
`FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin WHERE ` +
|
|
strings.Join(where, " AND ") + ` GROUP BY l.vin`
|
|
return SQLQuery{Text: `SELECT COALESCE(SUM(x.latest_mileage_km), 0) FROM (` + inner + `) x`, Args: args}
|
|
}
|
|
|
|
func parseMileageProtocols(raw string) []string {
|
|
seen := map[string]bool{}
|
|
values := make([]string, 0, 3)
|
|
for _, item := range strings.Split(raw, ",") {
|
|
protocol := strings.ToUpper(strings.TrimSpace(item))
|
|
switch protocol {
|
|
case "GB32960", "JT808", "YUTONG_MQTT":
|
|
if !seen[protocol] {
|
|
seen[protocol] = true
|
|
values = append(values, protocol)
|
|
}
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
func appendMileageProtocolFilter(where []string, args []any, column string, protocols []string) ([]string, []any) {
|
|
placeholders := make([]string, len(protocols))
|
|
for index, protocol := range protocols {
|
|
placeholders[index] = "?"
|
|
args = append(args, protocol)
|
|
}
|
|
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
|
}
|
|
|
|
func mileageProtocolOrder(column string, protocols []string) string {
|
|
parts := make([]string, 0, len(protocols)+1)
|
|
parts = append(parts, "CASE "+column)
|
|
for index, protocol := range protocols {
|
|
parts = append(parts, "WHEN '"+protocol+"' THEN "+strconv.Itoa(index+1))
|
|
}
|
|
parts = append(parts, "ELSE 999 END")
|
|
return strings.Join(parts, " ") + ", " + column + " ASC"
|
|
}
|
|
|
|
// mileageDailySelectionOrder keeps the configured protocol priority among
|
|
// usable daily facts while preventing a zero-increment source from masking a
|
|
// positive result reported by another enabled protocol. If every enabled
|
|
// protocol reports zero, the configured priority still decides the source.
|
|
func mileageDailySelectionOrder(mileageColumn, protocolColumn string, protocols []string) string {
|
|
return "CASE WHEN COALESCE(" + mileageColumn + ", 0) > 0 THEN 0 ELSE 1 END, " +
|
|
mileageProtocolOrder(protocolColumn, protocols)
|
|
}
|
|
|
|
func appendVINListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
|
return appendListFilter(where, args, column, raw, 0)
|
|
}
|
|
|
|
func appendCSVListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
|
return appendListFilter(where, args, column, raw, 200)
|
|
}
|
|
|
|
func appendListFilter(where []string, args []any, column string, raw string, maxValues int) ([]string, []any) {
|
|
seen := map[string]bool{}
|
|
values := make([]string, 0)
|
|
for _, item := range strings.Split(raw, ",") {
|
|
value := strings.TrimSpace(item)
|
|
if value == "" || seen[value] || (maxValues > 0 && len(values) >= maxValues) {
|
|
continue
|
|
}
|
|
seen[value] = true
|
|
values = append(values, value)
|
|
}
|
|
if len(values) == 0 {
|
|
return where, args
|
|
}
|
|
placeholders := make([]string, len(values))
|
|
for index, value := range values {
|
|
placeholders[index] = "?"
|
|
args = append(args, value)
|
|
}
|
|
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
|
}
|
|
|
|
func appendVehicleDateScope(where []string, args []any, vinColumn, dateColumn, raw string) ([]string, []any) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return where, args
|
|
}
|
|
var parsed map[string]string
|
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || len(parsed) == 0 {
|
|
return append(where, "1 = 0"), args
|
|
}
|
|
cutoffs := make(map[string]string, len(parsed))
|
|
for vin, cutoff := range parsed {
|
|
vin = strings.ToUpper(strings.TrimSpace(vin))
|
|
cutoff = strings.TrimSpace(cutoff)
|
|
if vin == "" || cutoff == "" {
|
|
continue
|
|
}
|
|
cutoffs[vin] = cutoff
|
|
}
|
|
if len(cutoffs) == 0 {
|
|
return append(where, "1 = 0"), args
|
|
}
|
|
vins := make([]string, 0, len(cutoffs))
|
|
for vin := range cutoffs {
|
|
vins = append(vins, vin)
|
|
}
|
|
sort.Strings(vins)
|
|
predicates := make([]string, 0, len(vins))
|
|
for _, vin := range vins {
|
|
predicates = append(predicates, "("+vinColumn+" = ? AND "+dateColumn+" >= ?)")
|
|
args = append(args, vin, cutoffs[vin])
|
|
}
|
|
return append(where, "("+strings.Join(predicates, " OR ")+")"), args
|
|
}
|
|
|
|
func buildLimitOffset(query url.Values) (int, int) {
|
|
return parseSQLPageSize(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0)
|
|
}
|
|
|
|
func parseLimitOffset(rawLimit, rawOffset string) (int, int) {
|
|
return parseSQLPageSize(rawLimit, 20), parsePositive(rawOffset, 0)
|
|
}
|
|
|
|
func parseSQLPageSize(raw string, fallback int) int {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value <= 0 {
|
|
return fallback
|
|
}
|
|
if value > maxSQLPageSize {
|
|
return maxSQLPageSize
|
|
}
|
|
return value
|
|
}
|
|
|
|
func mustInt(value string) int {
|
|
n, _ := strconv.Atoi(value)
|
|
return n
|
|
}
|
|
|
|
const dailyGeographyProjectionSQL = `COALESCE(g.province,''),COALESCE(g.city,''),COALESCE(g.region,''),COALESCE(DATE_FORMAT(g.location_time,'%Y-%m-%dT%H:%i:%s.%f+08:00'),''),CASE COALESCE(g.status,'PENDING') WHEN 'RESOLVED' THEN '已解析' WHEN 'NO_LOCATION' THEN '当天无有效定位' WHEN 'ERROR' THEN '解析失败,后台重试中' ELSE '后台待解析' END`
|