Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/gzev/daily_report.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
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.
2026-10-26 18:05:59 +08:00

221 lines
7.0 KiB
Go

package gzev
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type ReportDay struct {
Date string
Rows, Known int64
}
type DailyReport struct {
Date, From, Through, Latest, LatestKnown, LastSuccess string
Expected, Fetched, RetryDates int
AuthExpired bool
Recent []ReportDay
Missing []string
}
// LoadDailyReport reads one consistent database snapshot. NULL mileage stays missing;
// a reported zero counts as known data.
func LoadDailyReport(ctx context.Context, db *sql.DB, account, from string, now time.Time) (DailyReport, error) {
today := ShanghaiToday(now)
first, err := ParseDate(from)
if err != nil || !first.Before(today) {
return DailyReport{}, errors.New("report start must be before today")
}
r := DailyReport{Date: today.Format("2006-01-02"), From: from, Through: today.AddDate(0, 0, -1).Format("2006-01-02")}
r.Expected = int(today.Sub(first).Hours() / 24)
tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead})
if err != nil {
return r, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),last_success_at FROM gzev_mileage_day WHERE account=? AND stat_date BETWEEN ? AND ? ORDER BY stat_date`, account, from, r.Through)
if err != nil {
return r, err
}
seen := map[string]bool{}
var latestSuccess time.Time
for rows.Next() {
var date string
var success time.Time
if err = rows.Scan(&date, &success); err != nil {
rows.Close()
return r, err
}
seen[date] = true
r.Fetched++
r.Latest = date
if success.After(latestSuccess) {
latestSuccess = success
}
}
err = rows.Err()
rows.Close()
if err != nil {
return r, err
}
if !latestSuccess.IsZero() {
r.LastSuccess = latestSuccess.In(today.Location()).Format("2006-01-02 15:04:05")
}
for d := first; d.Before(today); d = d.AddDate(0, 0, 1) {
date := d.Format("2006-01-02")
if !seen[date] {
r.Missing = append(r.Missing, date)
}
}
var known sql.NullString
err = tx.QueryRowContext(ctx, `SELECT DATE_FORMAT(MAX(stat_date),'%Y-%m-%d') FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? AND hydrogen_km IS NOT NULL`, account, from, r.Through).Scan(&known)
if err != nil {
return r, err
}
r.LatestKnown = known.String
recentFrom := today.AddDate(0, 0, -7).Format("2006-01-02")
if recentFrom < from {
recentFrom = from
}
rows, err = tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),COUNT(*),COUNT(hydrogen_km) FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? GROUP BY stat_date ORDER BY stat_date`, account, recentFrom, r.Through)
if err != nil {
return r, err
}
for rows.Next() {
var d ReportDay
if err = rows.Scan(&d.Date, &d.Rows, &d.Known); err != nil {
rows.Close()
return r, err
}
r.Recent = append(r.Recent, d)
}
err = rows.Err()
rows.Close()
if err != nil {
return r, err
}
err = tx.QueryRowContext(ctx, `SELECT active FROM gzev_auth_alert WHERE account=?`, account).Scan(&r.AuthExpired)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return r, err
}
err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gzev_mileage_attempt WHERE account=? AND stat_date BETWEEN ? AND ?`, account, from, r.Through).Scan(&r.RetryDates)
if err != nil {
return r, err
}
return r, tx.Commit()
}
func (r DailyReport) Message() (string, string) {
conclusion := "同步已覆盖全部目标日期。"
switch {
case r.AuthExpired:
conclusion = "登录已失效,需要更新登录状态;已入库数据仍保留。"
case len(r.Missing) > 0:
conclusion = fmt.Sprintf("同步尚未补齐,仍有 %d 个日期待采集。", len(r.Missing))
default:
for _, d := range r.Recent {
if d.Date == r.Through {
if d.Known == 0 {
conclusion = "日期已补齐,但昨天上游纯氢里程全部为空,仍需后续重采。"
} else if d.Known < d.Rows {
conclusion = "日期已补齐,但昨天仍有部分车辆的上游纯氢里程缺失。"
}
}
}
}
show := func(v string) string {
if v == "" {
return "暂无"
}
return v
}
var b strings.Builder
fmt.Fprintf(&b, "**结论:%s**\n\n统计截至:%s(北京时间,昨天及更早完整自然日)\n\n日期采集:%d / %d 天;最新采集日期:%s\n\n最新有纯氢里程值的日期:%s\n\n最后成功采集:%s(北京时间)\n\n待补日期:%d 天;失败待重试:%d 天\n", conclusion, r.Through, r.Fetched, r.Expected, show(r.Latest), show(r.LatestKnown), show(r.LastSuccess), len(r.Missing), r.RetryDates)
if len(r.Missing) > 0 {
n := len(r.Missing)
if n > 14 {
n = 14
}
fmt.Fprintf(&b, "\n待补日期(最多列出14天):%s\n", strings.Join(r.Missing[:n], "、"))
}
if r.AuthExpired {
fmt.Fprintf(&b, "\n[更新广州平台登录状态](https://vehicle.d.lnoneos.com/gzev/session/)\n")
}
b.WriteString("\n最近7天:\n\n| 日期 | 车辆日记录 | 纯氢有值 | 纯氢缺失 |\n|---|---:|---:|---:|\n")
for _, d := range r.Recent {
fmt.Fprintf(&b, "| %s | %d | %d | %d |\n", d.Date, d.Rows, d.Known, d.Rows-d.Known)
}
b.WriteString("\n有值包含平台明确上报的 0;缺失不计为 0。日期采集完成不代表上游字段齐全。每小时 :15 持续同步及重采近期数据。")
return "纯氢里程每日汇报 · " + r.Date, b.String()
}
type reportReceipt struct {
Date string `json:"date"`
AttemptedAt time.Time `json:"attempted_at"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
}
// Persist the attempt before sending: an ambiguous timeout must not cause a
// duplicate push when the same day's service is started again.
func DeliverDailyReport(path, date string, send func() error) (bool, error) {
sent := false
err := recoveryLock(path, func() error {
var receipt reportReceipt
b, err := os.ReadFile(path)
if err == nil {
if err = json.Unmarshal(b, &receipt); err != nil {
return errors.New("invalid daily report receipt")
}
if receipt.Date == date {
if receipt.AcceptedAt != nil {
return nil
}
return errors.New("daily report already attempted; delivery acceptance unknown, inspect before retrying")
}
} else if !os.IsNotExist(err) {
return err
}
receipt = reportReceipt{Date: date, AttemptedAt: time.Now().UTC()}
write := func() error {
f, err := os.CreateTemp(filepath.Dir(path), ".daily-report-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
err = json.NewEncoder(f).Encode(receipt)
if err == nil {
err = f.Sync()
}
closeErr := f.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
return os.Rename(f.Name(), path)
}
if err = write(); err != nil {
return err
}
if err = send(); err != nil {
return err
}
now := time.Now().UTC()
receipt.AcceptedAt = &now
if err = write(); err != nil {
return err
}
sent = true
return nil
})
return sent, err
}